Extending Marten's Linq Support

INFO

The Linq parsing and translation to Postgresql JSONB queries, not to mention Marten's own helpers and model, are pretty involved and this guide isn't exhaustive. Please feel free to ask for help in Marten's Gitter room linked above if there's any Linq customization or extension that you need.

Marten allows you to add Linq parsing and querying support for your own custom methods. Using the (admittedly contrived) example from Marten's tests, say that you want to reuse a small part of a Where() clause across different queries for "IsBlue()." First, write the method you want to be recognized by Marten's Linq support:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Baseline;
using Baseline.Reflection;
using Marten.Linq.Fields;
using Marten.Linq.Filters;
using Marten.Linq.Parsing;
using Marten.Linq.SqlGeneration;
using Marten.Testing.Harness;
using Shouldly;
using Weasel.Postgresql;
using Weasel.Postgresql.SqlGeneration;
using Xunit;

namespace Marten.Testing.Linq
{
    public class using_custom_Linq_parser_plugins_Tests
    {
        #region sample_using_custom_linq_parser

        [Fact]
        public void query_with_custom_parser()
        {
            using (var store = DocumentStore.For(_ =>
            {
                _.Connection(ConnectionSource.ConnectionString);

                // IsBlue is a custom parser I used for testing this
                _.Linq.MethodCallParsers.Add(new IsBlue());
                _.AutoCreateSchemaObjects = AutoCreate.All;

                // This is just to isolate the test
                _.DatabaseSchemaName = "isblue";
            }))
            {
                store.Advanced.Clean.CompletelyRemoveAll();


                var targets = new List<ColorTarget>();
                for (var i = 0; i < 25; i++)
                {
                    targets.Add(new ColorTarget {Color = "Blue"});
                    targets.Add(new ColorTarget {Color = "Green"});
                    targets.Add(new ColorTarget {Color = "Red"});
                }

                var count = targets.Where(x => x.IsBlue()).Count();

                targets.Each(x => x.Id = Guid.NewGuid());

                store.BulkInsert(targets.ToArray());

                using (var session = store.QuerySession())
                {
                    session.Query<ColorTarget>().Count(x => x.IsBlue())
                        .ShouldBe(count);
                }
            }
        }

        #endregion
    }

    public class ColorTarget
    {
        public string Color { get; set; }
        public Guid Id { get; set; }
    }

    public static class CustomExtensions
    {
        #region sample_custom-extension-for-linq

        public static bool IsBlue(this ColorTarget target)
        {
            return target.Color == "Blue";
        }

        #endregion
    }

    #region sample_IsBlue

    public class IsBlue: IMethodCallParser
    {
        private static readonly PropertyInfo _property = ReflectionHelper.GetProperty<ColorTarget>(x => x.Color);

        public bool Matches(MethodCallExpression expression)
        {
            return expression.Method.Name == nameof(CustomExtensions.IsBlue);
        }

        public ISqlFragment Parse(IFieldMapping mapping, ISerializer serializer, MethodCallExpression expression)
        {
            var locator = mapping.FieldFor(new MemberInfo[] {_property}).TypedLocator;

            return new WhereFragment($"{locator} = 'Blue'");
        }
    }

    #endregion
}

Note a couple things here:

  1. If you're only using the method for Linq queries, it technically doesn't have to be implemented and never actually runs
  2. The methods do not have to be extension methods, but we're guessing that will be the most common usage of this

Now, to create a custom Linq parser for the IsBlue() method, you need to create a custom implementation of the IMethodCallParser interface shown below:

using System.Linq.Expressions;
using Marten.Linq.Fields;
using Marten.Linq.SqlGeneration;
using Marten.Schema;
using Weasel.Postgresql.SqlGeneration;

namespace Marten.Linq.Parsing
{
    #region sample_IMethodCallParser
    /// <summary>
    /// Models the Sql generation for a method call
    /// in a Linq query. For example, map an expression like Where(x => x.Property.StartsWith("prefix"))
    /// to part of a Sql WHERE clause
    /// </summary>
    public interface IMethodCallParser
    {
        /// <summary>
        /// Can this parser create a Sql where clause
        /// from part of a Linq expression that calls
        /// a method
        /// </summary>
        /// <param name="expression"></param>
        /// <returns></returns>
        bool Matches(MethodCallExpression expression);

        /// <summary>
        /// Creates an ISqlFragment object that Marten
        /// uses to help construct the underlying Sql
        /// command
        /// </summary>
        /// <param name="mapping"></param>
        /// <param name="serializer"></param>
        /// <param name="expression"></param>
        /// <returns></returns>
        ISqlFragment Parse(IFieldMapping mapping, ISerializer serializer, MethodCallExpression expression);
    }

    #endregion
}

The IMethodCallParser interface needs to match on method expressions that it could parse, and be able to turn the Linq expression into part of a Postgresql "where" clause. The custom Linq parser for IsBlue() is shown below:

public static bool IsBlue(this ColorTarget target)
{
    return target.Color == "Blue";
}

snippet source | anchor

Lastly, to plug in our new parser, we can add that to the StoreOptions object that we use to bootstrap a new DocumentStore as shown below:

[Fact]
public void query_with_custom_parser()
{
    using (var store = DocumentStore.For(_ =>
    {
        _.Connection(ConnectionSource.ConnectionString);

        // IsBlue is a custom parser I used for testing this
        _.Linq.MethodCallParsers.Add(new IsBlue());
        _.AutoCreateSchemaObjects = AutoCreate.All;

        // This is just to isolate the test
        _.DatabaseSchemaName = "isblue";
    }))
    {
        store.Advanced.Clean.CompletelyRemoveAll();

        var targets = new List<ColorTarget>();
        for (var i = 0; i < 25; i++)
        {
            targets.Add(new ColorTarget {Color = "Blue"});
            targets.Add(new ColorTarget {Color = "Green"});
            targets.Add(new ColorTarget {Color = "Red"});
        }

        var count = targets.Where(x => x.IsBlue()).Count();

        targets.Each(x => x.Id = Guid.NewGuid());

        store.BulkInsert(targets.ToArray());

        using (var session = store.QuerySession())
        {
            session.Query<ColorTarget>().Count(x => x.IsBlue())
                .ShouldBe(count);
        }
    }
}

snippet source | anchor