10
\$\begingroup\$

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.Description, "update test";
    var detail = header.Details.First();
    detail.QuantityOrdered = 42;
    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 42... 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
{
    Untracked,
    Unchanged,
    Modified,
    Added,
    Deleted
}

I needed a way to somehow support navigation properties. 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
{
    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);
        }
    }
}

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

internal class SageEntityChangeTracker
{
    private readonly IDictionary<object, Entry> _entries = new Dictionary<object, Entry>();

    public void VerifyModifiedState()
    {
        foreach (var entry in _entries)
        {
            entry.Value.VerifyModifiedState(entry.Key);
        }
    }

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

        _entries[entity] = new Entry(entity, state);
    }

    public IEnumerable<dynamic> TrackedEntities(EntityState state)
    {
        var entries = _entries;
        return entries.Where(entry => entry.Value.State == state).Select(entry => entry.Key);
    }

    /// <summary>
    /// Sets the state of all tracked entities to 'Unchanged'.
    /// </summary>
    internal void AcceptChanges()
    {
        var entries = _entries;
        foreach (var entry in entries)
        {
            entry.Value.State = EntityState.Unchanged;
        }
    }

    /// <summary>
    /// Unregisters an entity from change tracking.
    /// </summary>
    public void Detach<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        if (_entries.ContainsKey(entity))
        {
            _entries.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
    {
        if (_entries.ContainsKey(entity))
        {
            _entries[entity].State = EntityState.Deleted;
        }
    }
}

The tracker uses a simple Entry object, which is responsible for storing the original values, and verifying whether an entity was modified:

/// <summary>
/// An object responsible for holding entity state and property values.
/// </summary>
internal class Entry
{
    private readonly IDictionary<string, object> _members;
    private readonly dynamic _entity;
    private readonly Type _entityType;

    public Entry(dynamic entity, EntityState state)
    {
        State = state;
        _entity = entity;

        _entityType = _entity.GetType();
        _members = _entityType.GetProperties(BindingFlags.Instance | BindingFlags.Public)
            .ToDictionary(property => property.Name, property => property.GetValue(entity));
    }

    public EntityState State { get; set; }

    public object this[string memberName]
    {
        get
        {
            object value;
            return _members.TryGetValue(memberName, out value) ? value : null;
        }
    }

    public void VerifyModifiedState(object entity)
    {
        if (State != EntityState.Unchanged)
        {
            // we don't want to override Added or Deleted states
            return;
        }

        var type = entity.GetType();
        if (type != _entityType)
        {
            throw new InvalidOperationException(string.Format("Entity type '{0}' was supplied, but type '{1}' was expected.", type.Name, _entityType.Name));
        }

        var members = type.GetProperties(BindingFlags.Instance | BindingFlags.Public);
        foreach (var member in members)
        {
            var currentValue = member.GetValue(entity);
            var originalValue = this[member.Name];
            if (!originalValue.Equals(currentValue))
            {
                State = EntityState.Modified;
                break;
            }
        }
    }

    public override bool Equals(object obj)
    {
        return _entity.Equals(obj);
    }

    public override int GetHashCode()
    {
        return _entity.GetHashCode();
    }
}

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), and it really feels like I should be extracting some of it into other types, ...but what?

The class encapsulates the Sage300-specifics (e.g. order of operations when inserting or updating master/child entities), but also exposes an Execute method for the SageQueryProvider to evaluate an Expression. It basically knows almost everything there is to know about the Sage300 View API, all while exposing an EF-like API to client code.

/// <summary>
/// Encapsulates a transaction.
/// </summary>
public abstract class SageContextBase : IDisposable
{
    private readonly SageCredential _credential;
    private readonly SageAppInfo _info;
    private readonly Session _session;
    private DBLink _db;

    protected SageContextBase(SageCredential credential, SageAppInfo info)
    {
        _tracker = new SageEntityChangeTracker();
        _session = new Session();
        _credential = credential;
        _info = info;
    }

    private readonly SageEntityChangeTracker _tracker;
    internal SageEntityChangeTracker ChangeTracker { get { return _tracker; } }

    /// <summary>
    /// Opens the database connection and initializes the <see cref="ViewSet{TEntity}"/> members.
    /// </summary>
    public void Open()
    {
        _session.Init(string.Empty, _info.AppId, _info.ProgramName, _info.AppVersion);
        _session.Open(_credential.UserId, _credential.Password, _credential.DatabaseName, DateTime.Now, 0);
        _db = _session.OpenDBLink(DBLinkType.Company, DBLinkFlags.ReadWrite);

        var properties = GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)
                                  .Where(p => p.CanRead && p.CanWrite && p.PropertyType.IsGenericType);

        foreach (var propertyInfo in properties)
        {
            var entityType = propertyInfo.PropertyType.GenericTypeArguments[0];
            var view = AddView(GetMappedViewId(entityType));

            var constructedType = typeof (ViewSet<>).MakeGenericType(entityType);
            var viewSetInstance = (ViewSet)Activator.CreateInstance(constructedType, view, this);

            propertyInfo.SetValue(this, viewSetInstance);
            _viewSets.Add(viewSetInstance);
        }

        OnModelComposing();
    }

    private readonly IList<ViewSet> _viewSets = new List<ViewSet>(); 
    public IReadOnlyList<ViewSet> ViewSets { get { return _viewSets.ToList(); } }

    /// <summary>
    /// Provides a hook for derived types to specify how views are composed.
    /// </summary>
    protected abstract void OnModelComposing();

    private readonly IDictionary<string, IViewComInterop> _views = new Dictionary<string, IViewComInterop>();

    private IViewComInterop AddView(string viewId)
    {
        if (_views.ContainsKey(viewId))
        {
            throw new InvalidOperationException("View '" + viewId + "' is already added.");
        }

        _views[viewId] = _db.OpenView(viewId);
        return _views[viewId];
    }

    // Executes the expression tree that is passed to it. 
    internal static object Execute<TEntity>(Expression expression, ViewSet<TEntity> viewSet)
        where TEntity : EntityBase
    {
        var constantExpression = expression as ConstantExpression;
        if (constantExpression != null)
        {
            if (constantExpression.Value is ViewSet<TEntity>)
            {
                return viewSet.Select(string.Empty);
            }
        }

        var filterFinder = new InnermostFilterFinder();
        var filterExpression = filterFinder.GetInnermostFilter(expression);
        var filter = string.Empty;
        if (filterExpression != null)
        {
            if (filterExpression.Arguments.Count > 1)
            {
                var lambdaExpression =
                    (LambdaExpression) ((UnaryExpression) (filterExpression.Arguments[1])).Operand;

                // Send the lambda expression through the partial evaluator.
                lambdaExpression = (LambdaExpression) Evaluator.PartialEval(lambdaExpression);

                // Get the filter string to pass to the Sage API.
                var visitor = new FilterVisitor<TEntity>(lambdaExpression.Body);
                filter = visitor.Filter;
            }

            switch (filterExpression.Method.Name)
            {
                case "Where":
                    return viewSet.Select(filter);
                case "Single":
                    var singleResult = viewSet.SingleOrDefault(filter);
                    if (singleResult == null)
                    {
                        throw new InvalidOperationException("Sequence contains more than one element.");
                    }
                    return singleResult;
                case "SingleOrDefault":
                    return viewSet.SingleOrDefault(filter);
                case "First":
                    var firstResult = viewSet.FirstOrDefault(filter);
                    if (firstResult == null)
                    {
                        throw new InvalidOperationException("Sequence contains no element matching specified criteria.");
                    }
                    return firstResult;
                case "FirstOrDefault":
                    return viewSet.FirstOrDefault(filter);

                case "Last":
                    var lastResult = viewSet.LastOrDefault(filter);
                    if (lastResult == null)
                    {
                        throw new InvalidOperationException("Sequence contains no element matching specified criteria.");
                    }
                    return lastResult;
                case "LastOrDefault":
                    return viewSet.LastOrDefault(filter);
                case "Count":
                    return viewSet.Count(filter);
                case "Any":
                    return viewSet.Any(filter);
                case "All":
                    return viewSet.All(filter);
                default:
                    throw new NotSupportedException("Method '" + filterExpression.Method.Name + "' is not currently supported by this provider.");
            }
        }

        return viewSet.Select(filter);
    }

    private static string GetMappedViewId(Type type)
    {
        var mapsToView = type.GetCustomAttribute<MapsToAttribute>();
        if (mapsToView == null)
        {
            throw new InvalidOperationException("Entity type is missing a MapsToAttribute.");
        }
        return mapsToView.Name;
    }

    public void Dispose()
    {
        foreach (var view in _views.Values)
        {
            view.Dispose();
        }

        _db.Dispose();
        _session.Dispose();
    }

    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);
        }

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

        _tracker.AcceptChanges();
    }

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

    private void OnViewSetException(Exception exception, ViewSet viewSet)
    {
        var session = viewSet.View.Parent.Parent;
        var sessionError = session.Errors.Count > 0
            ? session.Errors[0]
            : null;

        for (var i = 0; i < session.Errors.Count; i++)
        {
            Debug.Print(session.Errors[i].Message);
        }

        var message = sessionError == null ? exception.Message : sessionError.Message;
        if (exception is ViewException)
        {
            throw new ViewSetException(message, exception.InnerException);
        }

        throw new ViewSetException(message, exception);
    }

    internal TEntity ReadEntity<TEntity>() where TEntity : EntityBase
    {
        var viewSet = GetViewSet<TEntity>();
        try
        {
            var result = (TEntity)Activator.CreateInstance(typeof(TEntity));
            var properties = result.GetPropertyInfos();

            foreach (var property in properties.Where(property => property.ViewName == viewSet.View.ViewID && property.EditMode != EditMode.WriteOnly))
            {
                var value = viewSet.View.Fields.FieldByName(property.FieldName).Value;
                Debug.WriteLine("Read: View('{0}').Fields.FieldByName(\"{1}\").Value, returned '{2}'", property.ViewName, property.FieldName, property.Value);
                property.Property.SetValue(result, value);
            }

            ReadChildEntities(result);
            _tracker.Attach(result);
            return result;
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }

    private void ReadChildEntities<TEntity>(TEntity result) where TEntity : EntityBase
    {
        var navigationProperties = GetNavigationProperties(result);
        if (navigationProperties == null)
        {
            return;
        }

        foreach (var navigationProperty in navigationProperties)
        {
            var method = typeof(SageContextBase).GetMethod("GetNavigationChildEntities", BindingFlags.NonPublic | BindingFlags.Instance);
            var genericMethod = method.MakeGenericMethod(typeof(TEntity), navigationProperty.Value);
            dynamic entities = genericMethod.Invoke(this, new object[] { result, true });

            var info = navigationProperty.Key;
            dynamic list = info.GetValue(result);
            foreach (var entity in entities)
            {
                _tracker.Attach(entity);
                list.Add(entity);
            }
        }
    }

    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);
    }

    /// <summary>
    /// Inserts a single new record into a flat view, or of a detail record in a composed header/detail view.
    /// </summary>
    /// <param name="entity">The entity that contains the key field and values to insert.</param>
    private void Insert<TEntity>(TEntity entity)
        where TEntity : EntityBase
    {
        BeginInsert(entity);
        InsertChildEntities(entity);
        FinalizeInsert<TEntity>();
    }

    private void InsertChildEntities<TEntity>(TEntity entity)
        where TEntity : EntityBase
    {
        var navigationProperties = GetNavigationProperties(entity);
        if (navigationProperties == null)
        {
            return;
        }

        foreach (var navigationProperty in navigationProperties)
        {
            var method = typeof(SageContextBase).GetMethod("GetNavigationChildEntities", BindingFlags.NonPublic | BindingFlags.Instance);
            var genericMethod = method.MakeGenericMethod(typeof(TEntity), navigationProperty.Value);
            dynamic entities = genericMethod.Invoke(this, new object[] { entity, false });

            foreach (var childEntity in entities)
            {
                Insert(childEntity);
            }
        }
    }

    /// <summary>
    /// Begins the insertion of a header record into a composed header/detail view.
    /// </summary>
    private void BeginInsert<TEntity>(TEntity entity, bool process = true)
        where TEntity : EntityBase
    {
        var entityTypeName = entity.GetType().Name;
        var viewSet = GetViewSet<TEntity>();
        Debug.WriteLine("BeginInsert: " + entityTypeName);
        try
        {
            if (HasAutomaticKey<TEntity>())
            {
                viewSet.View.RecordCreate(ViewRecordCreate.DelayKey);
                Debug.WriteLine("Executed: View.RecordCreate({0}) - {1}", ViewRecordCreate.DelayKey, entityTypeName);
            }
            else
            {
                viewSet.View.RecordClear();
                Debug.WriteLine("Executed: View.RecordClear()");
            }

            var info = entity.GetPropertyInfos().ToList();
            if (process)
            {
                var writeOnlyFields = info.Where(property =>
                    property.ViewName == viewSet.View.ViewID
                    && property.KeyType == null
                    && property.EditMode == EditMode.WriteOnly)
                    .ToList();

                ProcessCommand(writeOnlyFields);
            }

            var fields = info.Where(property =>
                property.ViewName == viewSet.View.ViewID
                && (property.KeyType == null || property.KeyType == KeyType.Manual)
                && property.EditMode != EditMode.ReadOnly
                && property.EditMode != EditMode.WriteOnly)
                .ToList();

            WriteEntity(fields);
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }

    private void ProcessCommand<TEntity>(IEnumerable<EntityPropertyInfo<TEntity>> properties)
        where TEntity : EntityBase
    {
        var command = properties.SingleOrDefault(CanProcessField);
        if (command != null)
        {
            var viewSet = GetViewSet<TEntity>();
            viewSet.View.Process();
            Debug.WriteLine("Executed: View.Process(\"{0}\") - {1}", (short)command.Value, typeof(TEntity).Name);
        }
    }

    private readonly IReadOnlyList<string> _processFields = new List<string> { "PROCESSCMD", "FUNCTION" };

    private bool CanProcessField<TEntity>(EntityPropertyInfo<TEntity> info)
    {
        return _processFields.Contains(info.FieldName);
    }

    private static bool HasAutomaticKey<TEntity>()
    {
        return GetKeys<TEntity>().Any(key => key.KeyType == KeyType.GeneratedByView || key.KeyType == KeyType.GeneratedByHeader);
    }

    private static IEnumerable<EntityPropertyInfo<TEntity>> GetKeys<TEntity>()
    {
        return typeof(TEntity).GetPropertyInfos<TEntity>().Where(property => property.KeyType != null);
    }

    /// <summary>
    /// Finalizes the insertion of a header and its details, in a composed header/detail view.
    /// </summary>
    private void FinalizeInsert<TEntity>()
        where TEntity : EntityBase
    {
        var viewSet = GetViewSet<TEntity>();
        Debug.WriteLine("FinalizeInsert: " + typeof(TEntity).Name);
        try
        {
            viewSet.View.Insert();
            Debug.WriteLine("Executed: View.Insert() - " + typeof(TEntity).Name);
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }

    private void WriteEntity<TEntity>(IEnumerable<EntityPropertyInfo<TEntity>> properties)
        where TEntity : EntityBase
    {
        var viewSet = GetViewSet<TEntity>();
        try
        {
            foreach (var property in properties)
            {
                var value = property.Value;
                if (value == null)
                {
                    continue;
                }

                viewSet.View.Fields.FieldByName(property.FieldName).SetValue(value, false);
                Debug.WriteLine("Wrote: View('{0}').Fields.FieldByName(\"{1}\").SetValue('{2}')", property.ViewName, property.FieldName, value);
            }
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
        }
    }

    private void WriteKeys<TEntity>(TEntity entity, bool useManualKeysOnly = false)
        where TEntity : EntityBase
    {
        var viewSet = GetViewSet<TEntity>();
        var keys = entity.GetPropertyInfos().Where(property =>
            property.ViewName == viewSet.View.ViewID && property.KeyType != null && (!useManualKeysOnly || property.KeyType == KeyType.Manual));

        WriteEntity(keys);
    }

    private void Delete<TEntity>(TEntity entity) where TEntity : EntityBase
    {
        var entityTypeName = entity.GetType().Name;

        Debug.WriteLine("Delete: " + entityTypeName);
        var viewSet = GetViewSet<TEntity>();
        try
        {
            WriteKeys(entity);
            if (viewSet.View.Read(false))
            {
                Debug.WriteLine("Executed: View.Read(false) - " + entityTypeName);
                viewSet.View.Delete();
                Debug.WriteLine("Executed: View.Delete() - " + entityTypeName);
            }
            else
            {
                throw new InvalidKeyException();
            }
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }

    /// <summary>
    /// Updates a single new record into a flat view, or of a detail record in a composed header/detail view.
    /// </summary>
    /// <param name="entity">The entity that contains the key field and values to update.</param>
    /// <param name="useManualKeysOnly"></param>
    private void Update<TEntity>(TEntity entity, bool useManualKeysOnly = false) where TEntity : EntityBase
    {
        BeginUpdate(entity, useManualKeysOnly);
        UpdateModifiedChildEntities(entity); // ensures correct order of operations.
        FinalizeUpdate<TEntity>();
    }

    private void UpdateModifiedChildEntities<TEntity>(TEntity entity, bool useManualKeysOnly = false) where TEntity : EntityBase
    {
        var navigationProperties = GetNavigationProperties(entity);
        if (navigationProperties == null)
        {
            return;
        }

        foreach (var navigationProperty in navigationProperties)
        {
            var method = typeof(SageContextBase).GetMethod("GetNavigationChildEntities", BindingFlags.NonPublic | BindingFlags.Instance);
            var genericMethod = method.MakeGenericMethod(typeof(TEntity), navigationProperty.Value);
            dynamic entities = genericMethod.Invoke(this, new object[] { entity, false });

            var childViewSet = ViewSets.SingleOrDefault(set => set.ElementType == navigationProperty.Value);
            if (childViewSet != null)
            {
                foreach (var childEntity in entities)
                {
                    Update(childEntity, useManualKeysOnly);
                }
            }
        }
    }

    /// <summary>
    /// Begins the insertion of a header record into a composed header/detail view.
    /// </summary>
    /// <param name="entity">The entity that contains the key field and values to update.</param>
    /// <param name="useManualKeysOnly"></param>
    private void BeginUpdate<TEntity>(TEntity entity, bool useManualKeysOnly = false) where TEntity : EntityBase
    {
        var typeName = entity.GetType().Name;
        Debug.WriteLine("BeginUpdate: " + typeName);
        WriteKeys(entity, useManualKeysOnly);
        var viewSet = GetViewSet<TEntity>();
        try
        {
            viewSet.View.Read(false);
            Debug.WriteLine("Executed: View.Read(false) - " + typeName);

            var properties = entity.GetPropertyInfos()
                                    .Where(property => property.ViewName == viewSet.View.ViewID
                                                    && property.EditMode == EditMode.Editable
                                                    && property.KeyType == null)
                                    .ToList();

            ProcessCommand(properties);
            WriteEntity(properties);
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }

    /// <summary>
    /// Finalizes the updating of a header and its details, in a composed header/detail view.
    /// </summary>
    private void FinalizeUpdate<TEntity>() where TEntity : EntityBase
    {
        var typeName = typeof(TEntity).Name;
        Debug.WriteLine("FinalizeUpdate: " + typeName);
        var viewSet = GetViewSet<TEntity>();
        try
        {
            viewSet.View.Update();
            Debug.WriteLine("Executed: View.Update() - " + typeName);
        }
        catch (Exception exception)
        {
            OnViewSetException(exception, viewSet);
            throw;
        }
    }
}

Any & all feedback is welcome!

\$\endgroup\$
  • 1
    \$\begingroup\$ You might be able to remove the need for a base class by, well, copying Entity Framework's "entry" objects - basically a fancy Dictionary<string, object> that keeps track of the values pulled from the data store. Upon calling SaveChanges you get the entry for each tracked entity and compare the value in the entry to the current value of the same property on the entity itself. \$\endgroup\$ – Greg Burghardt Feb 27 '16 at 0:13
  • \$\begingroup\$ @GregBurghardt you're right - I haven't seen how EF does it, but I went ahead and implemented something like an Entry type based on your comment. Since I haven't received any reviews yet, I've updated the post with the revised code, and decided to include the whole SageContextBase class to provide more meat to chew on. You'll notice I'm still using a base class - to remove it I'd need to find another place to initialize navigation properties. \$\endgroup\$ – Mathieu Guindon Feb 29 '16 at 17:56
  • \$\begingroup\$ @MatsMug: Hm. True. The only way is to create "proxy" classes at run time for the navigation properties, and mark them virtual (a la NHibernate). \$\endgroup\$ – Greg Burghardt Feb 29 '16 at 19:21
2
+250
\$\begingroup\$

Just a few comments...

You've overcomplicated your Attach method:

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

    _entries[entity] = new Entry(entity, state);
}

All you want to do is either add a new entry or update the current entry's state.

/// <summary>
/// Registers an entity for change tracking.
/// </summary>
public void Attach<TEntity>(TEntity entity, EntityState state = EntityState.Unchanged) where TEntity : EntityBase
{
    Entry currentEntry;
    if (_entries.TryGetValue(entity, out currentEntry)
    {
        // There's no point in checking the current vs expected state
        // as you overwrite it if it's different anyway.
        currentEntry.State = state; 
    }
    else
    {
        _entries[entity] = new Entry(entity, state);
    }
}

I'd worry about using the entity as a key in a dictionary directly as you'd be relying on unknown and unverifiable GetHashCode and Equals implementations.

Do you configure keys for your entities? If you do, I'd recommend using a string key like $"{ typeName }_{ metadata.GetKeyValue(entity) }" instead. String's GetHashCode and Equals is definitely fine :).


In general, I think you need to introduce an abstraction over the entity types mapping. At the moment, some of your methods feel a bit too low level:

public Entry(dynamic entity, EntityState state)
{
    State = state;
    _entity = entity;

    // hmmmm.
    _entityType = _entity.GetType();
    _members = _entityType.GetProperties(BindingFlags.Instance | BindingFlags.Public)
        .ToDictionary(property => property.Name, property => property.GetValue(entity));
}

If you introduced some MetadataContainer (which could be a property on your SageContextBase)

 public class MetadataContainer
 {
     public IDictionary<string, object> GetConfiguredMembers(Type entityType) 
     {
         // cache?
     }
 }

Then your entry constructor becomes easier to grok:

public Entry(dynamic entity, EntityState state, MetadataContainer container)
{
    State = state;
    _entity = entity;
    _members = container.GetConfiguredMembers();
}
| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Very good points, especially about not controlling GetHashCode implementations of entity types! Some entities have keys, but I can't be sure, and whether and how many key fields are involved isn't under my control either - the suggested approach would work, but I'm stumped on handling entities that don't define a key field. I like the MetadataContainer idea a lot, great answer! \$\endgroup\$ – Mathieu Guindon Mar 2 '16 at 16:18
  • \$\begingroup\$ @Mat'sMug A lack of a key is particularly annoying! You could use key(s) falling back to all configured simple properties... But that might get quite large! It's a nice abstraction though - really good evolution so far :) \$\endgroup\$ – RobH Mar 2 '16 at 16:28
2
\$\begingroup\$
                if (lastResult == null)
                {
                    throw new InvalidOperationException("Sequence contains no element matching specified criteria.");
                }
                return lastResult;

For this sort of stuff, I'd write a throwIfNull wrapper. You have many of these null checks and most of them are on the wrong abstraction level, I think. Especially in Execute<TEntity>, what you really want to do in a big method like that is program on a higher abstraction level where all you're doing is applying operations, not having to filter various results.

You also have this:

    if (constantExpression != null)
    {
        if (constantExpression.Value is ViewSet<TEntity>)
        {
            return viewSet.Select(string.Empty);
        }
    }

Which is a nested if statement with no elses anywhere, looks like edit scarring to me.


/// <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);
    }

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

    _tracker.AcceptChanges();
}

Again, go for the higher level view if possible.

I'd prefer to read this:

/// <summary>
/// Commits all changes to the underlying Sage views.
/// </summary>
public void SaveChanges()
{
    DeleteAllOf(_tracker.TrackedEntities(EntityState.Deleted).ToList());
    InsertAllOf(_tracker.TrackedEntities(EntityState.Added).ToList());

    _tracker.VerifyModifiedState();

    UpdateAllOf(_tracker.TrackedEntities(EntityState.Modified).ToList());

    _tracker.AcceptChanges();
}

I'd even go one step further:

private List<TEntity> getTrackedEntitiesInState(EntityState state) where TEntity : EntityBase
{
    return _tracker.TrackedEntities(state).ToList();
}

/// <summary>
/// Commits all changes to the underlying Sage views.
/// </summary>
public void SaveChanges()
{
    DeleteAllOf(getTrackedEntitiesInState(EntityState.Deleted));
    InsertAllOf(getTrackedEntitiesInState(EntityState.Added));

    _tracker.VerifyModifiedState();

    UpdateAllOf(getTrackedEntitiesInState(EntityState.Modified));

    _tracker.AcceptChanges();
}

Maybe even use ICollection<TEntity> instead of List as expected types.


This code in GetNavigationChildEntities...

    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;
    }

It's the result of using a flag argument.

Maybe you can't get around the use of a flag argument. Maybe you can. In the situations where you can't, try to make the flag argument a clear point of separation. In this case, make a separate function so that the existence of the flag argument is easier to spot. Maybe in later refactorings you can spot some cases where you don't need the flag argument and can directly call the function you would have acccessed via the flag argument.

Also, if you can somehow iterate over viewSet.Select(string.Empty), is there anything stopping you from using result.AddRange(viewSet.Select(string.Empty))?


/// <summary>
/// Inserts a single new record into a flat view, or of a detail record in a composed header/detail view.
/// </summary>
/// <param name="entity">The entity that contains the key field and values to insert.</param>
private void Insert<TEntity>(TEntity entity)
    where TEntity : EntityBase
{
    BeginInsert(entity);
    InsertChildEntities(entity);
    FinalizeInsert<TEntity>();
}

This I like. Higher order functions. Start inserting the entity, insert the child entities, and then finish up.

Scrolling down, it's good that you did, because those three functions look pretty big.

| improve this answer | |
\$\endgroup\$
  • \$\begingroup\$ Nice answer! I can't assume the actual type will have an AddRange member. \$\endgroup\$ – Mathieu Guindon Mar 1 '16 at 12:26
  • \$\begingroup\$ @Mat'sMug that's a shame. \$\endgroup\$ – Pimgd Mar 1 '16 at 13:44
  • \$\begingroup\$ Actually, I could... because I'm the one making the ICollection<T> a List<T>... but I don't like using methods of the concrete type when the member is an ICollection<T>... see what I mean? \$\endgroup\$ – Mathieu Guindon Mar 1 '16 at 14:21
  • \$\begingroup\$ @Mat'sMug not really, I tend to program in Java, where Collection<T> comes with addAll(Collection<? extends E> c). \$\endgroup\$ – Pimgd Mar 1 '16 at 14:28
  • \$\begingroup\$ Sounds a lot like IList<T>. The problem is that it's the client code that defines the entities, and having to expose IList<T> navigation properties would come as a surprise, since Entity Frameworks' navigation properties must be ICollection<T>. \$\endgroup\$ – Mathieu Guindon Mar 1 '16 at 14:30
1
\$\begingroup\$

As can be inferred from this related post, there are serious coupling issues with this code. While it gracefully shields the client code from the Sage300 API, it's completely dependent on it... and that makes it literally untestable.

You need to wrap the Session, DBLink and View concrete 3rd-party types with your own interfaces, ISession, IDbLink and IView; implement these interfaces with SessionWrapper, DbLinkWrapper and ViewWrapper classes, so that none of this code uses the 3rd-party API directly.

Then you can mock an ISession, the IDbLink it spits out, and the IView that each ViewSet is encapsulating... and only then can you start testing the guts out of this class - and looking at everything going on here, it desperately needs tests.


The Execute static method clashes with the rest: it's LINQ provider stuff that would probably be better off extracted into its own type.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.