Following-up on my last question where I wrapped the Sage API with a familiar IRepository
interface, I decided to push the abstraction a step further, and... implement an actual LINQ provider.
So my client code looks like this:
class Program
{
static void Main(string[] args)
{
using (var context = new SageContext(/*redacted*/))
{
context.Open();
var orders =
from po in context.PurchaseOrderHeaders
where po.Number.Contains("123") && po.Lines > 0
select po;
foreach (var po in orders)
{
Console.WriteLine("PO Number: {0} ({1:d}) - {2}", po.Number, po.OrderDate, po.Key);
}
}
Console.ReadLine();
}
}
Looks exactly like it would with Entity Framework? That's intentional. Except... I don't have fancypants navigation properties. Yet (to be honest, not sure I have the slightest idea of how I'd go about this).
So, a bare-bones SageContext
looks like this:
public class SageContext : SageContextBase
{
public SageContext(string userId, string password, string dbName)
: base(new SageCredential(userId, password, dbName))
{
}
public ViewSet<PurchaseOrderHeader> PurchaseOrderHeaders { get; set; }
public ViewSet<PurchaseOrderHeaderOptionalField> PurchaseOrderHeaderOptionalFields { get; set; }
protected override void OnModelComposing()
{
//PurchaseOrderHeaders.View.Compose(new View[]{ /*compose views here*/ });
}
}
Every ViewSet<TEntity>
property is assigned a reference via reflection, in the base class (does that interfere with IDisposable
?) - the SageCredential
class isn't secure at all, I just added it because it nicely wraps closely-related values into a single-use object:
public abstract class SageContextBase : IDisposable
{
private const string AppId = "ABC";
private const string ProgramName = "ABC1234";
private const string AppVersion = "999";
private readonly SageCredential _credential;
private readonly Session _session;
private DBLink _db;
protected SageContextBase(SageCredential credential)
{
_session = new Session();
_credential = credential;
}
/// <summary>
/// Opens the database connection and initializes the <see cref="ViewSet{TEntity}"/> members.
/// </summary>
public void Open()
{
_session.Init(string.Empty, AppId, ProgramName, 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 = Activator.CreateInstance(constructedType, view);
propertyInfo.SetValue(this, viewSetInstance);
}
OnModelComposing();
}
/// <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)
{
// The expression must represent a query over the data source.
if (!(expression is MethodCallExpression))
{
throw new InvalidProgramException("No query over the data source was specified.");
}
// Find the call to Where() and get the lambda expression predicate.
var whereFinder = new InnermostWhereFinder();
var whereExpression = whereFinder.GetInnermostWhere(expression);
var lambdaExpression = (LambdaExpression)((UnaryExpression)(whereExpression.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);
var filter = visitor.Filter;
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();
}
}
And the rest of the magic happens in the ViewSet<TEntity>
class, which replaces my old RepositoryBase<TEntity>
base type:
public sealed class ViewSet<TEntity> : IOrderedQueryable<TEntity>
{
public ViewSet(IViewComInterop view)
{
_view = view;
Provider = new SageQueryProvider<TEntity>(view);
Expression = Expression.Constant(this);
}
public ViewSet(SageQueryProvider<TEntity> provider, Expression expression, IViewComInterop view)
{
if (provider == null)
{
throw new ArgumentNullException("provider");
}
if (expression == null)
{
throw new ArgumentNullException("expression");
}
if (!typeof (IQueryable<TEntity>).IsAssignableFrom(expression.Type))
{
throw new ArgumentOutOfRangeException("expression");
}
_view = view;
Provider = provider;
Expression = expression;
}
private readonly IViewComInterop _view;
public IViewComInterop View { get { return _view; } }
public IEnumerator<TEntity> GetEnumerator()
{
return (Provider.Execute<IEnumerable<TEntity>>(Expression)).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return (Provider.Execute<IEnumerable>(Expression)).GetEnumerator();
}
public IQueryProvider Provider { get; private set; }
public Expression Expression { get; private set; }
public Type ElementType { get { return typeof (TEntity); } }
private TEntity ReadEntity()
{
var result = (TEntity)Activator.CreateInstance(typeof(TEntity));
var properties = result.GetPropertyInfos();
foreach (var property in properties.Where(property => property.ViewName == View.ViewID))
{
var value = View.Fields.FieldByName(property.FieldName).Value;
property.Property.SetValue(result, value);
}
return result;
}
internal IEnumerable<TEntity> Select(string filter)
{
View.Browse(filter, true);
if (!View.GoTop())
{
yield break;
}
do
{
yield return ReadEntity();
}
while (View.GoNext());
}
public void Insert(TEntity entity)
{
throw new NotImplementedException();
}
public void Update(TEntity entity)
{
throw new NotImplementedException();
}
public void Delete(TEntity entity)
{
throw new NotImplementedException();
}
}
As you can see I still haven't implemented the rest of the CRUD operations - but now I'm happy enough about how this is set up, to do it.
Extending this code means following the Application Object Model specs and creating classes that map to each table View, and by decorating members with a MapsToAttribute
, everything "just works".
/// <summary>
/// An attribute that maps an entity type to a specific Sage view object,
/// or an entity property to a specific field in that view.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property)]
public class MapsToAttribute : Attribute
{
private readonly string _name;
/// <summary>
/// Maps an entity type to a specific Sage view,
/// or an entity property to a specific field in that view.
/// </summary>
/// <param name="name">The name of the mapped view or field.</param>
public MapsToAttribute(string name)
{
_name = name;
}
/// <summary>
/// Gets the name of the mapped view or field.
/// </summary>
public string Name { get { return _name; } }
}
But the real magic is here:
public class SageQueryProvider<TEntity> : IQueryProvider
{
private readonly IViewComInterop _view;
public SageQueryProvider(IViewComInterop view)
{
_view = view;
}
public IQueryable CreateQuery(Expression expression)
{
var elementType = TypeSystem.GetElementType(expression.Type);
try
{
return (IQueryable)Activator.CreateInstance(typeof (ViewSet<TEntity>).MakeGenericType(elementType), this, expression, _view);
}
catch (TargetInvocationException exception)
{
throw exception.InnerException;
}
}
public IQueryable<TResult> CreateQuery<TResult>(Expression expression)
{
var elementType = TypeSystem.GetElementType(expression.Type);
return (IQueryable<TResult>)Activator.CreateInstance(typeof(ViewSet<>).MakeGenericType(elementType), this, expression, _view);
}
public object Execute(Expression expression)
{
return SageContextBase.Execute(expression, new ViewSet<TEntity>(_view));
}
public TResult Execute<TResult>(Expression expression)
{
return (TResult)SageContextBase.Execute(expression, new ViewSet<TEntity>(_view));
}
}
I'm not too sure how I feel about the new ViewSet<TEntity>(_view))
going on here, but... it works.
Any & all feedback is welcome.