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!
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 wholeSageContextBase
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\$virtual
(a la NHibernate). \$\endgroup\$