Audit your Content Type Hierarchy


Shortly after you deploy a SharePoint site you probably start to wonder how your carefully designed content types are actually being used. And abused. More importantly I want to know if any of my feature based content types have been disconnected (unghosted) from the XML source file.

To that end I looked into extracting and visualizing the content type hierarchy to get the overview – and I found that it was already built by some nice chaps at codeplex (can be found here)! The community works like never before πŸ˜‰

I changed it in a number of ways (in my mind improved it):

  • The original only showed the content types declared at the site collection root, now I use a completely different way of finding inherited content types through the SPContentTypeUsage classes, to be able to show derived content types in sub sites as well
  • Using the SPContentTypeUsage will also give you derived list content types, so they can be displayed as well. There might be a large number of lists, so you can switch the list content type display on and off. Default is off.

    Caveat: To figure out whether a given (list) content type is a direct descendent of another I have to open the actual list content type and check. Some lists are special (like _catalogs) and will be rendered slightly off in the hierarchy (hierarchy “missing” but the list content type location will be there)

  • Added my very own detection of the ghosting/unghosting status of any given content type. There are three states: ghosted (connected to xml source), unghosted (disconnected from xml source) and “DB only” (created through the UI or OM, not from a feature)

At the end of the day pretty much all of the code has been changed, while keeping the layout. I’m still grateful for the very nice first step provided and I will post this code back to the codeplex site. Hopefully the guys there will accept the modified code.
[Updated April 14: Patch posted now]

Detecting Ghosted/Unghosted State

I did a lot of digging to figure out how to get at the ghosting status of a content type. You can have a look in the wss content database (table “ContentTypes”) to see if your content types are disconnected or not (column “definition” is null – see my other blog on this). There’s even a dedicated stored procedure that will tell you the ghosting state of a given content type (named “proc_IsContentTypeGhosted”).

I really did not want to directly call stored procedures on the database, so I had a good long hard look at the properties available through the SPContentType class (and many others through reflector) and came up with the following conditions for detecting the state:

  • Is it a feature based content type, by checking “FeatureID != null”, if not mark it as “DB only”. Note that this is actually a private field, so I have to go the long way around and fetch it through reflection.
  • If it is feature based on the version > 0 then it has been unghosted. The version number seem to work well for this. It will always be 0 for ghosted content types and be incremented by one every time somebody modifies (and thereby unghost it). According to the xml schema you can specify this field in your content type definition, but it will (fortunately) not make it to the database so the detection seems sound.

    If you make new versions of the xml file, the server won’t really notice (see post here).

Disclaimer: This algorithm works well for me, seems reasonable sensible, but there might be an edge case that I’m not aware of.

The Result

Here is what it looks like:

conenttypehierarchy.png

The page is rather long – you are seeing about half of it. It’s also slow, but that’s hardly a performance issue you should care to fix πŸ˜‰

The Code

You need to create a feature to deploy the page, the feature.xml I use:

<Feature xmlns="http://schemas.microsoft.com/sharepoint/" Id="2D9921AE-D263-4e2b-B4F7-ABDD6223C8B0"
   Scope="Site"
   Title="My Administration Utilities (Site collection)"
   Description="My adminsitration tools at the site collection level">

   <ElementManifests>
      <ElementManifest Location="ContentTypeHierarchy.xml" />
   </ElementManifests>
</Feature>

The referenced ContentTypeHierarchy.xml file (will create a link in the Gallery section of site settings):

<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<CustomAction Id="ContentTypeHierarchy"
   GroupId="Galleries"
   Location="Microsoft.SharePoint.SiteSettings"
   Sequence="0"
   Title="Site Content Types Hierarchy"
   Description="Displays a hierarchy of content types.">

   <UrlAction Url="_layouts/ContentTypeHierarchy.aspx"/>
</CustomAction>
</Elements>

And finally the magic page, ContentTypeHierarchy.aspx (deployed to the TEMPLATE\Layouts folder) (you can download it here, you’ll have to rename it back to aspx):

<%@ Page Language="C#" MasterPageFile="~/_layouts/application.master" %>

<%@ Assembly Name="Microsoft.SharePoint, Version=12.0.0.0, Culture=neutral,PublicKeyToken=71e9bce111e9429c" %>
<%@ Import Namespace="Microsoft.SharePoint" %>
<%@ Import Namespace="Microsoft.SharePoint.WebControls" %>
<%@ Import Namespace="System.Data.SqlClient" %>
<%@ Import Namespace="System.Reflection" %>
<%@ Register TagPrefix="wssuc" TagName="ToolBar" Src="~/_controltemplates/ToolBar.ascx" %>
<%@ Register TagPrefix="wssuc" TagName="ToolBarButton" Src="~/_controltemplates/ToolBarButton.ascx" %>
<asp:Content ID="Content2" runat="server" ContentPlaceHolderID="PlaceHolderPageTitleInTitleArea">
	Content Types Hierarchy
</asp:Content>
<asp:Content ID="Content3" runat="server" ContentPlaceHolderID="PlaceHolderPageDescription">
	This page shows all Site Content Types and their hierarchical relationships
</asp:Content>
<asp:Content ID="Content4" runat="server" ContentPlaceHolderID="PlaceHolderMain">

	<table border="0" width="100%" cellspacing="0" cellpadding="0">
		<tr>
			<td id="mngfieldToobar">
				<wssuc:ToolBar id="onetidMngFieldTB" runat="server">
					<template_buttons>
					  <wssuc:ToolBarButton runat="server" Text="<%$Resources:wss,multipages_createbutton_text%>" id="idAddField" ToolTip="<%$Resources:wss,mngctype_create_alt%>" NavigateUrl="ctypenew.aspx" ImageUrl="/_layouts/images/newitem.gif" AccessKey="C" />

						<table cellpadding="1" cellspacing="0" border="0">
							<tr>
								<td class="ms-toolbar" nowrap style="padding:0px">
									<asp:CheckBox runat="server" CssClass="ms-toolbar" Text="Show lists" id="cbList" AutoPostBack="true" />
								</td>
							</tr>
						</table>                

						<table cellpadding="1" cellspacing="0" border="0">
							<tr>
								<td class="ms-toolbar" nowrap style="padding: 0px">
									<asp:CheckBox runat="server" CssClass="ms-toolbar"  Text="Show descriptions" id="cbDesc" AutoPostBack="true" Checked="true" />
								</td>
							</tr>
						</table>
					</template_buttons>
				</wssuc:ToolBar>
			</td>
		</tr>
	</table>
	<asp:PlaceHolder ID="phHierarchy" runat="server" />
</asp:Content>

<script runat="server">
    Hashtable contentTypesShown = new Hashtable();

    protected override void OnLoad(EventArgs e)
    {
		base.OnLoad(e);

		try {
			EnsureChildControls();
			cbDesc.CheckedChanged += new EventHandler(CheckedChanged);
			cbList.CheckedChanged += new EventHandler(CheckedChanged);
			BuildHierarchy();
		}
		catch (Exception x) {
			OutputHtml(x.Message);
		}
	}

	void CheckedChanged(object sender, EventArgs e) {
		BuildHierarchy();
	}

	public void BuildHierarchy() {
		EnsureChildControls();
		phHierarchy.Controls.Clear();
        contentTypesShown.Clear();
		SPSite site = SPControl.GetContextSite(Context);
		SPContentTypeCollection types = site.RootWeb.ContentTypes;

		SPContentTypeId id = types[0].Id;
		OutputHtml("<table style='font-size:10pt' border='0'" + " cellpadding='2' width='100%'><tr><td><ol>");
		ShowContentType(types[0]);
		OutputHtml("</ol></td></tr></table>");
	}

	public void OutputHtml(string html) {
		Literal l = new Literal();
		l.Text = html;
		phHierarchy.Controls.Add(l);
	}

	public void ShowContentType(SPContentType currenttype) {
		try {
			OutputHtml("<li><a class='ms-topnav'" +
				" href=\"" + currenttype.Scope.TrimEnd('/') +
				"/_layouts/ManageContentType.aspx?ctype=" +
				currenttype.Id.ToString() + "\">" + currenttype.Name +
				"</a>" + ExtraInfo(currenttype) +
				(cbDesc.Checked ? "<span class='ms-webpartpagedescription'>" + currenttype.Description + "</span>" : "") +
				"</li>");

			SPContentTypeId id = currenttype.Id;

			OutputHtml("<ol>");

			System.Collections.Generic.IList<SPContentTypeUsage> usages = SPContentTypeUsage.GetUsages(currenttype);

			int listCount = 0;
            foreach (SPContentTypeUsage usage in usages)
            {
                if (!contentTypesShown.ContainsKey(usage.Url + "|||" + usage.Id))
                {
                    contentTypesShown.Add(usage.Url + "|||" + usage.Id, true);

                    if (!usage.IsUrlToList)
                    {
                        using (SPWeb subSite = SPControl.GetContextSite(Context).OpenWeb(usage.Url))
                        {
                            SPContentType subType = subSite.ContentTypes[usage.Id];
                            if (subType.Parent.Id == id && subType.Parent.Id != subType.Id)
                            {
                                ShowContentType(subType);
                            }
                        }
                    }
                    else if (usage.IsUrlToList && cbList.Checked)
                    {
                        //1. Find web + list
                        //2. Open content type
                        listCount++;

                        Match m = Regex.Match(usage.Url, "^(?<web>.*?/)(Lists/)?(?<list>[^/]+)$");
                        if (m.Success)
                        {

                            try
                            {

                                using (SPWeb subSite = SPControl.GetContextSite(Context).OpenWeb(m.Groups["web"].Value))
                                {
                                    SPContentType subType = subSite.Lists[m.Groups["list"].Value].ContentTypes[usage.Id];
                                    if (subType.Parent.Id == id && subType.Parent.Id != subType.Id)
                                    {
                                        OutputHtml("<li>(List) <a class='ms-topnav'" +
                                            " href=\"" + m.Groups["web"] + "_layouts/ManageContentType.aspx?ctype=" +
                                            usage.Id.ToString() + "&list=" + subType.ParentList.ID.ToString() + "\">" + subType.Name + "</a> " + usage.Url + "</li>");
                                    }
                                }
                            }
                            catch (Exception e)
                            {
                                //exceptions occur for some of the "special" list, e.g. catalogs, then just list the list ;-)
                                OutputHtml("<li>(List) " + usage.Url + "</li>");
                            }
                        }
                    }
                }
            }

			OutputHtml("</ol>");
		}
		catch (Exception x) {
			OutputHtml("<b>Error:" + x.ToString() + "</b>");
		}

	}

	string ExtraInfo(SPContentType type) {
		string info = "";

		//Ghosted?
		// if the content type came from a feature and version > 0 then it's ghosted (as
		// far as I've been able to determine).
		// Version will not be greater than 0 for non-ghosted content types.
		// Alternative is to query DB directly (proc_IsContentTypeGhosted).

		//Need to find the featureId (if any) through reflection, since it's not a
		// public property)
		System.Reflection.PropertyInfo featureIdProp = type.GetType().GetProperty("FeatureId", BindingFlags.NonPublic | BindingFlags.Instance, null, typeof(Guid), Type.EmptyTypes, null);
		object featureId = featureIdProp.GetValue(type, null);

		if (type.Version > 0 && featureId != null && ((Guid)featureId) != Guid.Empty) {
			info += "Storage: <b>UNghosted (DB)</b>|";
		}
		else if (featureId != null && ((Guid)featureId) != Guid.Empty) {
			info += "Storage: Ghosted (Disk)|";
		}
		else {
			info += "Storage: DB only|";
		}

		if (type.ReadOnly) {
			info += "ReadOnly|";
		}
		if (type.Sealed) {
			info += "Sealed|";
		}
		info += "Scope:\"" + type.Scope + "\"|";

		info = info.TrimEnd('|').Replace("|", ", ");
		if (info.Length > 0) {
			info = " <i>(" + info + ")</i> ";
		}
		return info;
	}
</script>

Remember to reference the files in your solution manifest file and you should be good to go πŸ™‚

Summary

It works and I’m very happy with it. It is an invaluable tool to debug all sorts of content type related problems. Big thanks to the guys at codeplex for letting me hit the ground running on this.

What’s not in it? Site columns. Didn’t find a good way for it and the need seems limited.

Finally understand that this is not really an audit in the normal SharePoint sense, so you should also consider to enable auditing on content type changes as well. Even when you have locked down security properly accidents do happen (only for your fellow admins of course).

MOSS does .NET 3.5 Surprisingly Well


I’m sure that a lot of you guys out there are smarter than me and laugh at me writing this.

But…

MOSS does .NET 3.5 without any worries! It works as good as you could possibly hope for – I expected it not to πŸ˜‰

My experience is hampering me here, as I remember all the problems we had with .NET 2.0 for WSSv2 and/or SPS 2003 (hint: One of the two works) that effectively resulted in us staying with version 1.1 far longer than anyone wanted (same thing happened with the shift from 1.0). The average developer usually want to switch to the v.next right around the beta 2 comes out (don’t we ever get smarter?).

I just wished somebody had written it plainly sooner so I wouldn’t have to go through the hoops myself and we could have started to use .NET 3.5 much sooner.

It is slightly curious how this works though. In the IIS management your SharePoint site (or any plain ASP.NET web application) that you want to use .NET 3.5 in should be set to “ASP.NET 2.0.xx” mode – there is no 3.0/3.5 mode. They really should re-label the option in IIS to something less confusing (I haven’t checked how it’s labeled in IIS7).

How can this be? It’s been a while since I did compiler design courses (I had the “tiger” book) but I can still give some educated guesses:

  • The CLR is unchanged between 2.0, 3.0 and 3.5.
  • Lambda expressions and Linq are handled at compile time so the actual MSIL code are unchanged along with some new libraries – in my younger days we used to call it “syntactic sugar”
  • .NET already knows how to link to libraries of dihfferent versions, provided that they use the same version of the CLR (as far as I know there are three 1.0, 1.1 and 2.0 plus some beta versions in between). Your .NET 3.5 code will use the libraries associated with that version of .NET and the .NET 2.0 code will use its own libraries – the w3wp process that runs both the .NET 2.0 and 3.5 code (as the MOSS site will do) therefore loads both the 2.0 and 3.5 libraries as needed.

I apologize to everyone who already knew this and thinks it obviously should just work. You may be smarter or less experienced/pessimistic (is there a difference?)

Enjoy!