Saturday, November 19, 2011

Bug with using Linq 2 Sharepoint in anonymous mode with managed metadata

You most probably heard about problem with using Linq 2 Sharepoint in anonymous mode. August 2010 cumulative update fixed this problem. However recently we faced with one of variations of this problem: Linq 2 Sharepoint doesn’t work with managed metadata in anonymous mode. Below I will describe the problem.

Suppose that you have a list of products which have Applications managed metadata column which contains possible usage areas of the product (applications may be nested, i.e. term set is hierarchical). You use Linq 2 Sharepoint in order to generate business entities with strongly typed properties. What about Applications column? In won’t be mapped by default. In order to map it to list of business objects (IEnumerable<Application>) you need create POCO class Application by yourself which may look like this:

   1: public class Application
   2: {
   3:     public Guid Id { get; set; }
   4:     public string Name { get; set; }
   5:     public string Description { get; set; }
   6:     public Application Parent;
   7: }

(Linq 2 Sharepoint doesn’t map entities from term store). After that you need extend mapping generated by SPMetal and add mapping from field value to collection of business object. Check the following link for more details: Extending the Object-Relational Mapping. Idea is the following: you need to create partial class (for those class which was generated by SPMetal) and implement MapFrom/MapTo and Resolve methods. I won’t explain it in details (link above contains enough information about them), just show how it may look like in our example:

   1: public partial class Products : ICustomMapping
   2: {
   3:     [CustomMapping(Columns = new[] { Constants.APPLICATIONS_FIELD_NAME })]
   4:     public void MapFrom(object listItem)
   5:     {
   6:         var item = (SPListItem)listItem;
   7:         this.ApplicationsRaw = item[Constants.APPLICATIONS_FIELD_NAME] as TaxonomyFieldValueCollection;
   8:     }
   9:  
  10:     public void MapTo(object listItem)
  11:     {
  12:         var item = (SPListItem)listItem;
  13:         item[Constants.APPLICATIONS_FIELD_NAME] = this.ApplicationsRaw;
  14:     }
  15:  
  16:     public void Resolve(RefreshMode mode, object originalListItem, object databaseListItem)
  17:     {
  18:         ...
  19:     }
  20:  
  21:     private bool applicationsDirty = true;
  22:     private TaxonomyFieldValueCollection applicationsRaw;
  23:     private TaxonomyFieldValueCollection ApplicationsRaw
  24:     {
  25:         get
  26:         {
  27:             return applicationsRaw;
  28:         }
  29:         set
  30:         {
  31:             if (value == applicationsRaw)
  32:             {
  33:                 return;
  34:             }
  35:  
  36:             this.OnPropertyChanging(Constants.APPLICATIONS_FIELD_NAME, applicationsRaw);
  37:             applicationsRaw = value;
  38:             this.applicationsDirty = true;
  39:             this.OnPropertyChanged(Constants.APPLICATIONS_FIELD_NAME);
  40:         }
  41:     }
  42:  
  43:     private IEnumerable<Application> applications = new List<Application>();
  44:     public IEnumerable<Application> Applications
  45:     {
  46:         get
  47:         {
  48:             if (this.applicationsDirty)
  49:             {
  50:                 this.applications = ... // logic for mapping from Terms to Applications POCO
  51:                 this.applicationsDirty = false;
  52:             }
  53:             return this.applications;
  54:         }
  55:     }
  56: }

Now suppose that our web application is extended to Internet zone which allows anonymous access (http://example.com and http://public.example.com). And we need to retrieve list of Products using repository pattern (which uses Linq 2 Sharepoint in actual implementation of course) and it should work both for authenticated users and anonymous users. If we would not have managed metadata it would work (as I mentioned above August 2010 CU fixed this and tests showed that it really works :) ). However in our case it won’t work for anonymous.

Let’s see how it is used:

   1: var repository = SharePointServiceLocator.GetCurrent(SPContext.Current.Site)
   2:     .GetInstance<IProductsRepository>();
   3: repository.SetWeb(SPContext.Current.Web);
   4: var products = repository.GetAll();

Here we also use SharePointServiceLocator, but this is topic for other posts. When we set web and call GetAll() method it initializes Linq 2 Sharepoint data context (ProductsGeneratedCodeDataContext in this case) using URL of specified web:

   1: public class ProductsByApplicationRepository : IProductsByApplicationRepository
   2: {
   3:     private override ProductsGeneratedCodeDataContext getDataContext(SPWeb web)
   4:     {
   5:         return new ProductsGeneratedCodeDataContext(web.Url);
   6:     }
   7:     ...
   8: }

When this code is executed for authenticated users, web site URL points to the default authentication zone http://example.com, for anonymous users it points to http://public.example.com. And for anonymous users it doesn’t work – list of managed metadata terms are not mapped. If we will check in debugger MapFrom() method:

   1: [CustomMapping(Columns = new[] { Constants.APPLICATIONS_FIELD_NAME })]
   2: public void MapFrom(object listItem)
   3: {
   4:     var item = (SPListItem)listItem;
   5:     this.ApplicationsRaw = item[Constants.APPLICATIONS_FIELD_NAME] as TaxonomyFieldValueCollection;
   6: }

we will find that item.Web points to http://public.example.com. And for TaxonomyFieldValueCollection all terms contain Label, but TermGuid property is empty (00000000-0000-0000-0000-000000000000).

The first try to fix it is to use RunWithElevatedPrivileges() and open site in default authentication zone (http://example.com). I wrote about it here: Open SPSite under RunWithElevatedPrivileges in proper zone:

   1: SPSecurity.RunWithElevatedPrivileges(() =>
   2: {
   3:     using (var site = new SPSite(SPContext.Current.Site.ID))
   4:     {
   5:         using (var web = site.OpenWeb(SPContext.Current.Web.ID))
   6:         {
   7:             var repository = SharePointServiceLocator.GetCurrent(site)
   8:                 .GetInstance<IProductsRepository>();
   9:             repository.SetWeb(web);
  10:             var products = repository.GetAll();
  11:         }
  12:     }
  13: });

After that when context is created – web URL points to http://example.com (i.e. to authenticated zone). But if we will check in debugger MapFrom() method we will see that item.Web.Url still points to http://public.example.com.

The problem is that internally Linq 2 Sharepoint uses SPContext.Current.Site and as result it points to anonymous zone. The following post contains explanation of this behavior: Linq to SharePoint: Query Across Site Collections. The root problem is in SPServerDataConnection:

   1: public void SPServerDataConnection(string url)
   2: {
   3:     if (SPContext.Current != null)
   4:     {
   5:         this.defaultSite = SPContext.Current.Site;
   6:         this.defaultWeb = (SPContext.Current.Web.Url == url) 
   7:             ? SPContext.Current.Web 
   8:             : this.defaultSite.OpenWeb(new Uri(url).PathAndQuery);
   9:     }
  10:     else
  11:     {
  12:         this.defaultSite = new SPSite(url);
  13:         this.defaultWeb = this.defaultSite.OpenWeb(new Uri(url).PathAndQuery);
  14:     }
  15:     if (!this.defaultWeb.Exists)
  16:     {
  17:         throw new ArgumentException
  18:             (Resources.GetString("CannotFindWeb", new object[] { url }));
  19:     }
  20:     this.defaultWebUrl = this.defaultWeb.ServerRelativeUrl;
  21:     this.openedWebs = new Dictionary<string, SPWeb>();
  22:     this.openedWebs.Add(this.defaultWebUrl, this.defaultWeb);
  23: }

In order to fix it we need to fake SPContext.Current and set it to null (as shown in code above in this case SPSite will be reopened). After code will be executed – we need to restore SPContext.Current. I.e. this is the same solution which was used for general Linq 2 Sharepoint in anonymous mode problem. Nice wrapper is shown here: Making Linq to SharePoint work for Anonymous users:

   1: public static class AnonymousContextSwitch
   2: {
   3:     public static void RunWithElevatedPrivelligesAndContextSwitch(SPSecurity.CodeToRunElevated secureCode)
   4:     {
   5:         try
   6:         {
   7:             //If there is a SPContext.Current object and there is no known user, we need to take action
   8:             bool shouldSwitch = (SPContext.Current != null && SPContext.Current.Web.CurrentUser == null);
   9:  
  10:             HttpContext backupCtx = HttpContext.Current;
  11:             try
  12:             {
  13:                 if (shouldSwitch)
  14:                     HttpContext.Current = null;
  15:  
  16:                 SPSecurity.RunWithElevatedPrivileges(secureCode);
  17:             }
  18:             finally
  19:             {
  20:                 if (shouldSwitch)
  21:                     HttpContext.Current = backupCtx;
  22:             }
  23:         }
  24:         catch (Exception x)
  25:         {
  26:             // log
  27:         }
  28:     }
  29: }

So the final fix will look like this:

   1: var site = SPContext.Current.Site;
   2: var web = SPContext.Current.Web;
   3: AnonymousContextSwitch.RunWithElevatedPrivelligesAndContextSwitch(
   4:     () =>
   5:         {
   6:             var repository = SharePointServiceLocator.GetCurrent(site)
   7:                 .GetInstance<IProductsRepository>();
   8:             repository.SetWeb(web);
   9:             var products = repository.GetAll();
  10:         });

Note that we can’t use SPContext.Current inside AnonymousContextSwitch because it will be null.

After this in MapFrom() method item.Web will point to http://example.com and TermGuid won’t be empty for all terms and will be mapped successfully. From my vision this problem looks like a bug in Sharepoint.

No comments:

Post a Comment