10
\$\begingroup\$

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.

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

There are a number of missed opportunities that could be considered as bugs here.


Your Execute<TEntity> implementation in SageContextBase is poorly adapted from the MSDN example. Good job on making it generic, but this:

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

..is causing a weird issue. Consider:

var orders = from po in context.PurchaseOrderHeaders select po;

and:

var orders = context.PurchaseOrders;

Are these two snippets equivalent? No! The second one is a ConstantExpression without a MethodCallExpression, which makes your Execute method throw a surprising exception whenever there isn't an explicit projection / Select call.

Additionally, this part:

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

Assumes there's always a Where clause in your query, which you probably don't want to assume, especially if you're simply selecting all rows from a small support view/table.

You can account for the ConstantExpression like this:

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

And you can account for a missing Where clause with a simple null check, like this:

var whereFinder = new InnermostWhereFinder();
var whereExpression = whereFinder.GetInnermostWhere(expression);
var filter = string.Empty;
if (whereExpression != null)
{

And now your client code can have this rather intuitive code run without any issues:

var foo = context.SomeEntity.ToList();

You haven't listed the InnermostWhereFinder class, but by the naming of the local variables involved I suspect it's not accounting for other filtering methods, and so these queries would throw potentially surprising exceptions:

var foo = context.Something.Single(s => s.Id == 42);
var foo = context.Something.SingleOrDefault(s => s.Id == 42);
var foo = context.Something.First(s => s.Id == 42);
var foo = context.Something.FirstOrDefault(s => s.Id == 42);
var foo = context.Something.Last(s => s.Id == 42);
var foo = context.Something.LastOrDefault(s => s.Id == 42);
var foo = context.Something.Count(s => s.Foo == "42");
var foo = context.Something.Any();
var foo = context.Something.All(s => s.IsActive);

The provider should delegate anything it can't handle to LINQ-to-Objects, instead of blowing up. Matt Warren has a series of articles on MSDN that go into more details than the little walkthrough you used to do this.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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