Może zainteresują Cię pozostałe posty z cyklu Mapowanie SPListItem na obiekt:

  1. Mapowanie SPListItem na obiekt – Wprowadzenie
  2. Mapowanie SPListItem na obiekt – Wrapper
  3. Mapowanie SPListItem na obiekt – implicit/explicit conversion
  4. Mapowanie SPListItem na obiekt – LINQ to SharePoint
  5. Mapowanie SPListItem na obiekt – AutoMapper
  6. Mapowanie SPListItem na obiekt – Field Mapper (aktualnie czytasz)

Na sam koniec zostawiłem mapowanie, które najczęściej używam i które załatwia większość problemów, na jakie mogłeś natrafić w poprzednich mapowaniach.

Field Mapper (nie potrafiłem znaleźć lepszej nazwy) polega w założeniach na mapowaniu pól SharePointa na określoną własność klasy – czyli coś w stylu AutoMappera, z tą różnicą, iż konwersja obiektu SharePoint na zwykły obiekt i vice versa następuje w jednym miejscu i jest z unifikowana tak by można ją było wykorzystać dla każdego obiektu. Czyli ogólnie otrzymujemy uniwersalny konwerter AutoMappera + Wrapper na obiekt bez konieczności operowania na SPListItem.

Rozwiązanie to zostało również zaimplementowane, w SharePoint Guidance (SPG), w wersji pierwszej było dość skąpe i tak naprawdę nawet na nie, nie zwróciłem uwagi, zaś w wersji 2.0, rozwiązanie było już bardziej rozsądne i uniwersanle. To co ja zrobiłem to przeniosłem kilka swoich pomysłów na implementację dostępną w SPG, dzięki czemu zamiast podawać stringi, działałem na expressions, zamiast podawać nazwę pola, podaje jego Guid dodatkowo niektóre pola konwertuje na własny typ gdyż to co udostępnia SPG zawiera pewne niedociągłości takie jak:

  • Konwersja pól typu lookup – konwertuje do postaci 1#;nazwa;
  • Konwersja linków – brak możliwości rozsądnego zarządzania tytułem linka;
  • Konwersja pól użytkowników – taki sam problem jak przy lookup;
  • Konwersja pól tylko do odczytu – ładnie działa konwersja z SPListItem na obiekt, zaś z obiektu na SPListItem już jest problem;
  • Konwersja pól obliczeniowych – znów pole ma postać coś#;nazwa.

Przy najmniej na te problemy ja się natknąłem, a pewnie to nie są wszystkie, w szczególności kiedy wprowadza się własny typ pola.

Całe poniższe rozwiązanie opiera się o SPG – dokładnie mówiąc na odpowiednio zmodyfikowanych i kilku dodatkowych bazowych klasach.

By dopasować SPG do swoich potrzeb, pierwszą zmianę jaką zrobiłem to wyedytowałem klasę FieldToEntityPropertyMapping odpowiedzialną za trzymanie pary połączenia pola SharePoint z własnością w klasie, zmodyfikowana wersja wygląda tak:

public class FieldToEntityPropertyMapping
{
    public Guid ListFieldId { get; set; }
    public PropertyInfo EntityProperty { get; set; }
    public string InternalName { get; set; }
}

Teraz by to wykorzystać w następujący sposób:

ListItemFieldMapper.AddMapping(ActivityTypeFields.Id, item => item.Id);
ListItemFieldMapper.AddMapping(ActivityTypeFields.UniqueId, item => item.UniqueId);
ListItemFieldMapper.AddMapping(ActivityTypeFields.Title, item => item.Title);
ListItemFieldMapper.AddMapping(ActivityTypeFields.ActivityTypeSymbol, item => item.Symbol);
ListItemFieldMapper.AddMapping(ActivityTypeFields.ActivityTypeNameWithSymbol, item => item.NameWithSymbol);

Musiałem zmodyfikować klasę odpowiedzialną za przechowywanie mapowań i konwersje obiektu na SPListItem i na odwrót – ListItemFieldMapper:

public class ListItemFieldMapper<TEntity> where TEntity : new()
{
    private readonly string EntityTypeFullName = typeof(TEntity).FullName;

    private List<FieldToEntityPropertyMapping> _fieldMappings = new List<FieldToEntityPropertyMapping>();

    public TEntity CreateEntity(SPListItem listItem)
    {
        var entity = new TEntity();

        foreach (FieldToEntityPropertyMapping fieldMapping in _fieldMappings)
        {
            EnsureListFieldId(listItem, fieldMapping);
            fieldMapping.EntityProperty.SetValue(entity, listItem[fieldMapping.ListFieldId], null);
        }
        return entity;

    }

    public List<FieldToEntityPropertyMapping> FieldMappings
    {
        get { return _fieldMappings; }
    }

    public void FillSPListItemFromEntity(SPListItem listItem, TEntity entity)
    {
        foreach (FieldToEntityPropertyMapping fieldMapping in _fieldMappings)
        {
            EnsureListFieldId(listItem, fieldMapping);
            if (!listItem.Fields[fieldMapping.ListFieldId].ReadOnlyField)
                listItem[fieldMapping.ListFieldId] = fieldMapping.EntityProperty.GetValue(entity, null);
        }
    }

    public void AddMapping(Guid fieldId, Expression<Func<TEntity, object>> property)
    {
        _fieldMappings.Add(new FieldToEntityPropertyMapping { EntityProperty = GetPropertyInfo(property), ListFieldId = fieldId });
    }

    public void AddMapping(string internalName, Expression<Func<TEntity, object>> property)
    {
        _fieldMappings.Add(new FieldToEntityPropertyMapping { EntityProperty = GetPropertyInfo(property), InternalName = internalName });
    }

    private PropertyInfo GetPropertyInfo(Expression<Func<TEntity, object>> property)
    {
        Expression body = property.Body;

        MemberExpression memberExpression;

        if (body is MemberExpression)
        {
            memberExpression = (MemberExpression)body;
        }
        else if (body.NodeType == ExpressionType.Convert && ((UnaryExpression)body).Operand is MemberExpression)
        {
            memberExpression = (MemberExpression)((UnaryExpression)body).Operand;
        }
        else
        {
            string errorMessage = string.Format(CultureInfo.CurrentCulture, "The lambda expression '{0}' should point to a valid Property",
                                                property.Body);
            throw new ListItemFieldMappingException(errorMessage);
        }

        return (PropertyInfo)memberExpression.Member;
    }

    private void EnsureListFieldId(SPListItem item, FieldToEntityPropertyMapping fieldMapping)
    {
        try
        {
            if (fieldMapping.ListFieldId != Guid.Empty)
            {
                var ensuredField = item.Fields[fieldMapping.ListFieldId];
            }
            else
            {
                var ensuredField = item.Fields.GetFieldByInternalName(fieldMapping.InternalName);
                fieldMapping.ListFieldId = ensuredField.Id;
            }

        }
        catch (ArgumentException argumentException)
        {
            string errorMessage = string.Format(CultureInfo.CurrentCulture
                                                , "SPListItem '{0}' does not have a field with Id '{1}' which was mapped to property: '{2}' for entity '{3}'."
                                                , item.Name
                                                , fieldMapping.ListFieldId
                                                , fieldMapping.EntityProperty.Name
                                                , EntityTypeFullName);

            throw new ListItemFieldMappingException(errorMessage, argumentException);
        }
    }
}

Powyższy kod nie zawiera „warunków” dla pól lookup, linków, pól tylko do odczytów itp. Jeżeli chcecie zaimplementować swoją własną obsługę ich to należy to zrobić w metoda. Nie podaje tego kodu z powodu iż nie jest to niezbędne by z tym działać a z własnej implementacji nie jestem zadowolony i nie chcę szerzyć rozwiązania które nie należy do najlepszych: 

public TEntity CreateEntity(SPListItem listItem) {…}

public void FillSPListItemFromEntity(SPListItem listItem, TEntity entity) {…}

By obsłużyć linki stworzyłem sobie taką klasę (daje mi ona możliwość określenia tytułu linku):

public class HyperLinkEntity
{
    public string Url { get; set; }
    public string Title { get; set; }

    public static implicit operator HyperLinkEntity(SPFieldUrlValue field)
    {
        var entity = new HyperLinkEntity();
        entity.Url = field.Url;
        entity.Title = field.Description;

        return entity;
    }

    public static implicit operator SPFieldUrlValue(HyperLinkEntity entity)
    {
        var field = new SPFieldUrlValue();
        field.Url = entity.Url;
        field.Description = entity.Title;

        return field;
    }
}

Zaś dla lookup taką:

public class LookupEntity
{
    public int Id { get; set; }
    public string Value { get; set; }
    public Guid WebId { get; set; }
    public Guid SiteId { get; set; }

    public static implicit operator LookupEntity(SPFieldLookupValue field)
    {
        var entity = new LookupEntity();
        entity.Id = field.LookupId;
        entity.Value = field.LookupValue;

        return entity;
    }

    public static implicit operator SPFieldLookupValue(LookupEntity entity)
    {
        return new SPFieldLookupValue(entity.Id, string.Empty);
    }

    public static implicit operator SPFieldUserValue(LookupEntity entity)
    {
        SPFieldUserValue userValue;

        using (var site = new SPSite(entity.SiteId))
        {
            using (var web = site.OpenWeb(entity.WebId))
            {
                userValue = new SPFieldUserValue(web, entity.Id, string.Empty);
            }
        }

        return userValue;
    }

    public static implicit operator LookupEntity(SPFieldUserValue field)
    {
        var entity = new LookupEntity();
        entity.Id = field.LookupId;
        entity.Value = field.LookupValue;
        entity.WebId = field.User.ParentWeb.ID;
        entity.SiteId = field.User.ParentWeb.Site.ID;

        return entity;
    }
}

Jeżeli chcecie je wykorzystać to proszę bardzo, na przykład można tak to zaimplementować (podam przykład, ale można się zastanowić jak jakimś uniwersalnym systemem, który można wstrzykiwać lub który będzie „rozpoznany” w runtimie):

// for create
if (fieldMapping.EntityProperty.PropertyType == typeof(HyperLinkEntity))
{
    SPFieldUrlValue value = new SPFieldUrlValue(listItem[fieldMapping.ListFieldId] as string);
    HyperLinkEntity lookupEntity = value;
    fieldMapping.EntityProperty.SetValue(entity, lookupEntity, null);
}

// for fill
if (fieldMapping.EntityProperty.PropertyType == typeof(HyperLinkEntity))
{
    var value = (fieldMapping.EntityProperty.GetValue(entity, null) as HyperLinkEntity);
    if (value == null)
    {
        listItem[fieldMapping.ListFieldId] = null;
    }
    else
    {
        SPFieldUrlValue v = value;
        if (string.IsNullOrEmpty(v.ToString()))
        {
            listItem[fieldMapping.ListFieldId] = null;
        }
        else
        {
            listItem[fieldMapping.ListFieldId] = v;
        }
    }
}

Nulle są bardzo ważne, mimo iż wydaje się to kompletnie głupie to SharePoint lepiej funkcjonował z Nullami niż z pustymi polami lookup (choć w reflektorze jak sprawdzałem to nie powinno mieć znaczenia). Jednak następowały problemy potem bezpośrednio na liście, typu jedno pole zawiera błędne dane. Dziwne ale prawdziwe. Powodowało to, że nie tylko edycja listy z poziomu UI nie działała ale także mapowanie na obiekt miało swoje problemy (zamiast null tak jak być powinno był przypisany obiekt z pustymi danymi – czyli błąd w integracji danych co chyba do dobrych rzeczy nie należy).

Mając tak przygotowany konwerter można przejść do dalszej implementacji rozwiązania. By z unifikować dostęp w obie strony do modelu SharePointa trzeba było stworzyć jeszcze jedną dodatkową warstwę, nazwijmy ją Repozytorium – wcześniej robiłem to w Wrapperze na podstawie metod statycznych, w implicit/explicit też. Celem repozytorium jest odpytanie się SharePointa o dane i zwrócenie kolekcji obiektów jak i wrzucenie/aktualizacja obiektu na SharePointcie po dokonaniu na nim zmian.

By móc cokolwiek wrzucać, trzeba mieć najpierw co. W tym celu wyodrębniłem część wspólną dla wszystkich moich obiektów – tą, którą uważałem za istotną, można to oczywiście rozszerzyć o kolejne pola:

public abstract class BaseEntity
{
    public int Id { get; set; }
    public Guid UniqueId { get; set; }
    public string Title { get; set; }
}

Po której następnie wszystkie obiekty dziedziczyły:

public class ActivityType : BaseEntity
{
    public string NameWithSymbol { get; private set;}
    public string Symbol { get; set; }
}

Mając już obiekt na którym można działać mogłem przejść do implementacji repozytorium. Ze względu na to, że większość operacji będzie taka sama – dodaj, usuń, aktualizuj, jak i wykonaj zapytanie na liście (nie zależnie jakie zapytanie, SPQuery jest uniwersalne) wyodrębniłem część wspólną (coś w stylu List-Based Repositories):

// interfejs
public interface IBaseEntityRepository<TEntity>
{
    TEntity GetEntityById(int id);
    TEntity GetEntityByUniqueId(Guid uniqueId);

    void AddEntity(TEntity item);
    void SystemAddEntity(TEntity item, bool incrementListItemVersion);
    void SystemAddEntity(TEntity item);

    void UpdateEntity(TEntity item);
    void SystemUpdateEntity(TEntity item, bool incrementListItemVersion);
    void SystemUpdateEntity(TEntity item);

    void DeleteEntity(int id);
    void DeleteEntity(Guid uniqueId);

    IList<TEntity> FindAllEntities();

    IList<TEntity> FindAllEntitiesByQuery(SPQuery query);
    TEntity GetEntityByQuery(SPQuery query);
    TEntity GetEntityByQuery(SPQuery query, bool asAdmin);
}

// implementacja interfejsu
public abstract class BaseEntityRepository<TEntity> : IBaseEntityRepository<TEntity>
    where TEntity : BaseEntity, new()
{
    private ListItemFieldMapper<TEntity> _listItemFieldMapper = new ListItemFieldMapper<TEntity>();

    protected Guid ListId { get; set; }
    protected SPList List { get; set; }
    protected ILogger Logger { get; set; }

    protected ListItemFieldMapper<TEntity> ListItemFieldMapper
    {
        get
        {
            return _listItemFieldMapper;
        }
    }

    protected virtual void Initialize(string configKey)
    {
        var hierarchicalConfig = SharePointServiceLocator.Current.GetInstance<IConfigManager>();
        ListId = hierarchicalConfig.GetFromPropertyBag<Guid>(configKey, SPContext.Current.Web);

        List = SPContext.Current.Web.Lists[ListId];
        Logger = SharePointServiceLocator.Current.GetInstance<ILogger>();
    }

    protected virtual void Initialize(string configKey, SPWeb web)
    {
        var hierarchicalConfig = SharePointServiceLocator.Current.GetInstance<IConfigManager>();
        Logger = SharePointServiceLocator.Current.GetInstance<ILogger>();

        Logger.TraceToDeveloper("hierarchicalConfig is null: " + (hierarchicalConfig == null).ToString());

        ListId = hierarchicalConfig.GetFromPropertyBag<Guid>(configKey, web);
        List = web.Lists[ListId];
        
    }

    public TEntity GetCurrentEntity()
    {
        if (SPContext.Current.ListItem == null)
        {
            var id = HttpContext.Current.Request.QueryString["ID"];

            if(!string.IsNullOrEmpty(id))
            {
                return GetEntityById(int.Parse(id));
            }
        }

        return ListItemFieldMapper.CreateEntity(SPContext.Current.ListItem);
    }

    public TEntity GetEntityById(int id)
    {
        var listItem = List.GetItemById(id);

        return ListItemFieldMapper.CreateEntity(listItem);
    }

    public TEntity GetEntityByUniqueId(Guid uniqueId)
    {
        var listItem = List.GetItemByUniqueId(uniqueId);

        return ListItemFieldMapper.CreateEntity(listItem);
    }

    public void AddEntity(TEntity item)
    {
        var listItem = List.AddItem();

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);
        listItem.Update();

        item.Id = listItem.ID;

        try
        {
            item.UniqueId = listItem.UniqueId;
        }
        catch (Exception)
        {
        }
    }

    public void SystemAddEntity(TEntity item, bool incrementListItemVersion)
    {
        var listItem = List.AddItem();

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);
        listItem.SystemUpdate(incrementListItemVersion);

        item.Id = listItem.ID;
        try
        {
            item.UniqueId = listItem.UniqueId;
        }
        catch (Exception)
        {
        }
    }

    public void SystemAddEntity(TEntity item)
    {
        var listItem = List.AddItem();

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);
        listItem.PerformSystemUpdate();

        item.Id = listItem.ID;
        try
        {
            item.UniqueId = listItem.UniqueId;
        }
        catch (Exception)
        {
        }
    }

    public void UpdateEntity(TEntity item)
    {
        var listItem = List.GetItemByUniqueId(item.UniqueId);

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);

        listItem.Update();
    }

    public void SystemUpdateEntity(TEntity item, bool incrementListItemVersion)
    {
        var listItem = List.GetItemByUniqueId(item.UniqueId);

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);

        listItem.SystemUpdate(incrementListItemVersion);
    }

    public void SystemUpdateEntity(TEntity item)
    {
        var listItem = List.GetItemByUniqueId(item.UniqueId);

        ListItemFieldMapper.FillSPListItemFromEntity(listItem, item);

        listItem.PerformSystemUpdate();
    }

    public void DeleteEntity(int id)
    {
        var listItem = List.GetItemById(id);

        listItem.Delete();
    }

    public void DeleteEntity(Guid uniqueId)
    {
        var listItem = List.GetItemByUniqueId(uniqueId);

        listItem.Delete();
    }

    public IList<TEntity> FindAllEntities()
    {
        Logger.TraceToDeveloper("Executing FindAllEntities in BaseEntityRepository.");

        var entities = new List<TEntity>();

        SPListItemCollection collection = List.Items;

        foreach(SPListItem item in collection)
        {
            TEntity entity = ListItemFieldMapper.CreateEntity(item);
            entities.Add(entity);
        }

        return entities;
    }

    public IList<TEntity> FindAllEntitiesByQuery(SPQuery query)
    {
        Logger.TraceToDeveloper("Executing query in FindAllEntitiesByQuery(SPQuery query) in BaseEntityRepository. Query: " + query.Query);

        var entities = new List<TEntity>();

        SPListItemCollection collection = List.GetItems(query);

        foreach (SPListItem item in collection)
        {
            TEntity entity = ListItemFieldMapper.CreateEntity(item);
            entities.Add(entity);
        }

        return entities;
    }

    public TEntity GetEntityByQuery(SPQuery query)
    {
        Logger.TraceToDeveloper("Executing query in GetEntityByQuery(SPQuery query) in BaseEntityRepository. Query: " + query.Query);

        var entities = new List<TEntity>();

        SPListItemCollection collection = List.GetItems(query);

        foreach (SPListItem item in collection)
        {
            TEntity entity = ListItemFieldMapper.CreateEntity(item);
            entities.Add(entity);
            break;
        }

        return entities.Count == 0 ? default(TEntity) : entities[0];
    }

    public TEntity GetEntityByQuery(SPQuery query, bool asAdmin)
    {
        Logger.TraceToDeveloper("Executing query in GetEntityByQuery(SPQuery query, bool asAdmin) in BaseEntityRepository. Query: " + query.Query);
        var entities = new List<TEntity>();
        
        SPSecurity.RunWithElevatedPrivileges(delegate()
                                             {
                                                 using(SPSite site = new SPSite(List.ParentWeb.Site.ID))
                                                 {
                                                     using(SPWeb web = site.OpenWeb(List.ParentWeb.ID))
                                                     {
                                                         var list = web.Lists[ListId];

                                                         SPListItemCollection collection = list.GetItems(query);

                                                         foreach (SPListItem item in collection)
                                                         {
                                                             TEntity entity = ListItemFieldMapper.CreateEntity(item);
                                                             entities.Add(entity);
                                                             break;
                                                         }
                                                     }
                                                 }
                                             });
        
        return entities.Count == 0 ? default(TEntity) : entities[0];
    }
}

Nawet wydaje mi się, że pierwsza wersja SPG też miała takie rozwiązanie lub bardzo podobne – na pewno była zasada Base dla Entity i Base dla repozytorium.

Teraz niestety pojawia się kwestia, która mi się osobiście nie podobała, mianowicie w implementacji konkretnego repozytorium, trzeba powtórzyć metody Add/Edit itp z tą różnicą, iż wywoływane będą metody z podstawowego repozytorium. Minusem tego rozwiązania jest tworzenie wrappera na istniejące repozytorium, zaś plusem, że możemy nadać odpowiednie nazwy dla określonego typu repozytorium. Dla przykładu interfejs dla repozytorium wyglądał tak:

public interface IActivityTypeRepository
{
    ActivityType GetById(int id);
    ActivityType GetByUniqueId(Guid uniqueId);

    void Add(ActivityType item);
    void Update(ActivityType item);
    void Delete(int id);
    void Delete(Guid uniqueId);

    IList<ActivityType> FindAll();
}

Zaś implementacja interfejsu tak (zwróćcie uwagę na nazwę pobierania wszystkich elementów, już nie ma czegoś takiego jak Entity ale jest na przykład FindAll, może też być FindAllActivityTypesByFirstLetter(string letter) a następnie zbudowanie obiektu SPQuery i przekazanie go do metody GetEntitiesByQuery):

public sealed class ActivityTypeRepository : BaseEntityRepository<ActivityType>, IActivityTypeRepository
{
    public ActivityTypeRepository()
    {
        Initialize(Constants.ListActivityTypesIdConfigKey);

        ListItemFieldMapper.AddMapping(ActivityTypeFields.Id, item => item.Id);
        ListItemFieldMapper.AddMapping(ActivityTypeFields.UniqueId, item => item.UniqueId);
        ListItemFieldMapper.AddMapping(ActivityTypeFields.Title, item => item.Title);
        ListItemFieldMapper.AddMapping(ActivityTypeFields.ActivityTypeSymbol, item => item.Symbol);
        ListItemFieldMapper.AddMapping(ActivityTypeFields.ActivityTypeNameWithSymbol, item => item.NameWithSymbol);
    }

    public ActivityType GetById(int id)
    {
        return GetEntityById(id);
    }

    public ActivityType GetByUniqueId(Guid uniqueId)
    {
        return GetEntityByUniqueId(uniqueId);
    }

    public void Add(ActivityType item)
    {
        AddEntity(item);
    }

    public void Update(ActivityType item)
    {
        UpdateEntity(item);
    }

    public void Delete(int id)
    {
        DeleteEntity(id);
    }

    public void Delete(Guid uniqueId)
    {
        DeleteEntity(uniqueId);
    }

    public IList<ActivityType> FindAll()
    {
        return FindAllEntities();
    }
}

Dzięki takiemu podejściu przestałem mieć kłopoty z spaghetti w kodzie – już nie musiałem raz pisać SPListItem a raz obiekt klasy, działałem na obiektach tylko i wyłącznie, dla przykładu:

_activityTypeRepository = SharePointServiceLocator.Current.GetInstance<IActivityTypeRepository>();

ActivityType at = _activityTypeRepository.GetById(1);
at.Name = "jola";

_activityTypeRepository.Update(at);

Wadą całego tego podejścia jest to, iż by móc robić mapowanie musi być dostęp do SPWeb – bez tego, mapowanie nie zadziała, czyli przy asynchronicznych event receiverach, możemy napotkać na problem z dostępem do danych. W tym celu w klasie bazowej od repozytorium istnieje metoda Initialize przyjmująca jako parametr SPWeb. Jednakże trzeba o tym pamiętać, kiedy implementujecie konkretne metody od repozytorium, by nie złapać się na używaniu SPContext. Odpowiednio oprogramowane repozytorium da wam możliwość korzystania z mapowania gdzie tylko chcecie :)

Poza powyższym problemem nie widzę w tym podejściu jakiś większych wad, wszystko działa mi tak jak trzeba, jeżeli potrzebuje efektywnego podejścia do obiektu modelowego SharePointa to implementuje to w repozytorium (zwróćcie uwagę na metodę SystemAdd|UpdateEntity) – jedynie denerwującą rzeczą jest implementacja konkretnego dostępu do danych, a dokładniej mówiąc pisanie wrappera na metody z podstawowego repozytorium. Jednak jeżeli taki koszt mam zapłacić za to by pozbyć się spaghetti to jestem gotów go płacić co projekt SharePointowy :)

Tym o to postem kończę serię dotyczącą mapowania SPListItem na obiekty – możliwe, że wraz z większą popularnością SP2010 seria będzie się rozwijała, ale więcej do dodania dla wersji 2007 nie mam.

A wy widzicie jakieś niedociągłości w tym podejściu? Coś zrobilibyście inaczej? A może też do czegoś podobnego doszliście? Może macie pomysł jak to co już jest zaktualizować i rozszerzyć o kolejne przydatne rzeczy? Może nie poruszyłem jeszcze jakiegoś ważnego mapowania o którym wy wiecie a ja nie wiem? Z chęcią usłyszę co o tym wszystkim myślicie :)