Skip to main content
1 of 7
Mathieu Guindon
  • 75.6k
  • 18
  • 194
  • 468

Tracking Entity Changes (not EF)

So, I kept refactoring my Sage300 API wrapper - I wanted the client code to feel just like using Entity Framework - this is the closest I got to it:

using (var context = new SageContext(/*redacted credentials*/))
{
    context.Open();

    var header = context.PurchaseOrderHeaders.Single(po => po.Number == "NETAPI99");

    header.SetProperty(() => header.Description, "update test");
    var detail = header.Details.First();
    detail.QuantityOrdered = 2;
    context.SaveChanges();
}

The above selects a specific PurchaseOrderHeader entity, changes its Description to "update test", then selects the first PurchaseOrderDetail child entity and sets its QuantityOrdered to 2... and then sends the changes over to the Sage300 API.

How did this become possible? With quite a bit of code. I implemented a very basic change tracker - the first thing I needed was an EntityState:

public enum EntityState
{
    Unchanged,
    Modified,
    Added,
    Deleted
}

I needed a way to enforce a certain number of common members for all entity types, existing or future - for about a split second I thought of generating proxy types at runtime.. and then decided to keep calm and use a base class instead:

public abstract class EntityBase : IChangeTracking, INotifyPropertyChanged
{
    protected EntityBase()
    {
        InitializeNavigationChildProperties();
    }

    // ReSharper disable once CollectionNeverQueried.Local -- values acquired via reflection
    private readonly IDictionary<PropertyInfo, Type> _navigationProperties = new Dictionary<PropertyInfo, Type>();
    private void InitializeNavigationChildProperties()
    {
        _navigationProperties.Clear();

        var properties = from property in GetType().GetProperties()
                         where property.GetMethod != null && property.GetMethod.IsVirtual
                            && property.PropertyType.IsGenericType
                            && property.PropertyType.IsInterface
                         select property;

        foreach (var property in properties)
        {
            var entityType = property.PropertyType.GenericTypeArguments[0];
            var constructedType = typeof(List<>).MakeGenericType(entityType);
            dynamic list = Activator.CreateInstance(constructedType);
            property.SetValue(this, list);
            _navigationProperties.Add(property, entityType);
        }
    }

    /// <summary>
    /// Sets the value of the specified property and notifies change tracker.
    /// </summary>
    public void SetProperty<TValue>(Expression<Func<TValue>> propertyLambda, TValue value)
    {
        // Adapted from http://stackoverflow.com/a/672212/1188513
        var member = propertyLambda.Body as MemberExpression;
        if (member == null)
        {
            throw new ArgumentException(string.Format("Expression '{0}' refers to a method, not a property.", propertyLambda));
        }

        var propInfo = member.Member as PropertyInfo;
        if (propInfo == null)
        {
            throw new ArgumentException(string.Format("Expression '{0}' refers to a field, not a property.", propertyLambda));
        }

        var name = propInfo.Name;
        var property = GetType().GetProperties().Single(p => p.Name == name);
        property.SetValue(this, value);
        IsChanged = true;
        OnPropertyChanged(name);
    }

    public void AcceptChanges()
    {
        IsChanged = false;
    }

    public bool IsChanged { get; private set; }
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(string propertyName = null)
    {
        var handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

That SetProperty method is the best I could come up with to detect property changes without inheriting entity types at runtime and generating code that calls OnPropertyChanged - short of having proxy types, the client code needs to either call that SetProperty method, or make their entity types' setters call OnPropertyChanged.

So here's the SageEntityChangeTracker class, which is instantiated in the SageContextBase type:

internal class SageEntityChangeTracker
{
    private readonly IDictionary<EntityBase, EntityState> _trackedEntities = new Dictionary<EntityBase, EntityState>();

    /// <summary>
    /// Registers an entity for change tracking.
    /// </summary>
    public void Attach<TEntity>(TEntity entity, EntityState state = EntityState.Unchanged) where TEntity : EntityBase
    {
        if (_trackedEntities.ContainsKey(entity) && _trackedEntities[entity] == state)
        {
            return;
        }

        if (_trackedEntities.ContainsKey(entity))
        {
            _trackedEntities[entity] = state;
            return;
        }

        entity.PropertyChanged += HandleEntityPropertyChanged;
        _trackedEntities.Add(entity, state);
    }

    public IEnumerable<dynamic> TrackedEntities(EntityState state)
    {
        return _trackedEntities.Keys.Where(entity => _trackedEntities[entity] == state);
    }

    /// <summary>
    /// Sets the state of all tracked entities to 'Unchanged'.
    /// </summary>
    internal void AcceptChanges()
    {
        var keys = _trackedEntities.Keys.ToList();
        foreach (var entity in keys)
        {
            _trackedEntities[entity] = EntityState.Unchanged;
            entity.AcceptChanges();
        }
    }

    /// <summary>
    /// Unregisters an entity from change tracking.
    /// </summary>
    public void Detach<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        if (_trackedEntities.ContainsKey(entity))
        {
            entity.PropertyChanged -= HandleEntityPropertyChanged;
            _trackedEntities.Remove(entity);
        }
    }

    /// <summary>
    /// Attaches specified entity and marks it for insertion.
    /// </summary>
    public void Add<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        Attach(entity, EntityState.Added);
    }

    /// <summary>
    /// Marks an entity for deletion.
    /// </summary>
    public void Remove<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        _trackedEntities[entity] = EntityState.Deleted;
    }

    private void HandleEntityPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        var entity = sender as EntityBase;
        if (entity == null)
        {
            return;
        }

        if (_trackedEntities[entity] == EntityState.Unchanged)
        {
            _trackedEntities[entity] = EntityState.Modified;
        }
    }
}

The SageContextBase class has gotten pretty massive now (all the actual SageAPI CRUD is now in the base context class - it was all in ViewSet<T> before change tracking came along), so I'm only going to include the parts relevant to change tracking:

    public void Attach<TEntity>(TEntity entity, EntityState state) where TEntity : EntityBase
    {
        _tracker.Attach(entity, state);
    }

    public void Detach<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        _tracker.Detach(entity);
    }

    /// <summary>
    /// Commits all changes to the underlying Sage views.
    /// </summary>
    public void SaveChanges()
    {
        var deleted = _tracker.TrackedEntities(EntityState.Deleted).ToList();
        foreach (var entity in deleted)
        {
            Delete(entity);
        }

        var inserted = _tracker.TrackedEntities(EntityState.Added).ToList();
        foreach (var entity in inserted)
        {
            Insert(entity);
        }

        var updated = _tracker.TrackedEntities(EntityState.Modified).ToList();
        foreach (var entity in updated)
        {
            Update(entity);
        }

        _tracker.AcceptChanges();
    }

One of the cool things, is that I managed to get navigation properties to work - here's how I discover navigation properties, and fetch "child" entities:

private IDictionary<PropertyInfo, Type> GetNavigationProperties<TEntity>(TEntity entity)
{
    var fieldInfo = typeof(EntityBase).GetField("_navigationProperties", BindingFlags.NonPublic | BindingFlags.Instance);
    if (fieldInfo == null)
    {
        return null;
    }

    return (IDictionary<PropertyInfo, Type>)fieldInfo.GetValue(entity);
}

// ReSharper disable once UnusedMember.Local -- invoked via reflection
private ICollection<TChildEntity> GetNavigationChildEntities<TEntity, TChildEntity>(TEntity entity, bool readFromViewSet)
    where TEntity : EntityBase
    where TChildEntity : EntityBase
{
    var result = new List<TChildEntity>();
    var childViewSet = GetViewSet<TChildEntity>();
    if (childViewSet == null)
    {
        return result;
    }

    if (readFromViewSet)
    {
        // reading record from database; hydrate navigation properties by sending a SELECT query to the server.
        var constructedType = typeof(ViewSet<>).MakeGenericType(typeof(TChildEntity));
        dynamic viewSet = Convert.ChangeType(childViewSet, constructedType);
        WriteKeys(entity);
        foreach (var childEntity in viewSet.Select(string.Empty))
        {
            result.Add(childEntity);
        }

        return result;
    }

    // reading record from memory; return the ICollection<TChildEntity> navigation property itself.
    var properties = GetNavigationProperties(entity);
    if (properties == null)
    {
        return result;
    }

    var property = properties.Single(p => p.Key.PropertyType == typeof(ICollection<TChildEntity>));
    return (ICollection<TChildEntity>)property.Key.GetValue(entity);
}

private ViewSet<TEntity> GetViewSet<TEntity>() where TEntity : EntityBase
{
    return (ViewSet<TEntity>)ViewSets.SingleOrDefault(set => set.ElementType == typeof(TEntity));
}

All an entity type needs to do to enable this, is to expose a public virtual ICollection<TChildEntity> property, like this:

public virtual ICollection<PurchaseOrderDetail> Details { get; set; }

I haven't implemented navigation properties for "parent" entities yet, but it shouldn't be too hard at that point.

Any & all feedback is welcome.

Mathieu Guindon
  • 75.6k
  • 18
  • 194
  • 468