3

I have an enum describing a certain sorting order for a post:

enum PostOrder
{
    TitleAsc,
    TitleDesc,
    ScoreAsc,
    ScoreDesc,
}

and an extension method to reuse the ordering logic:

static class IQueryableExtensions
{
    public static IOrderedQueryable<Post> OrderByCommon(this IQueryable<Post> queryable, PostOrder orderBy)
        => orderBy switch
        {
            PostOrder.TitleAsc => queryable.OrderBy(x => x.Title),
            PostOrder.TitleDesc => queryable.OrderByDescending(x => x.Title),
            PostOrder.ScoreAsc => queryable.OrderBy(x => x.Score).ThenBy(x => x.Title),
            PostOrder.ScoreDesc => queryable.OrderByDescending(x => x.Score).ThenBy(x => x.Title),
            _ => throw new NotSupportedException(),
        };
}

The extension method works when used in a normal context but fails here:

var input = PostOrder.ScoreDesc;
var dbContext = new QuestionContext();
var users = dbContext.Users
    .Select(x => new
    {
        User = x,
        Top3Posts = x.Posts.AsQueryable()
            .OrderByCommon(input)
            .Take(3)
            .ToList()
    }).ToList();

with this error:

The LINQ expression 'MaterializeCollectionNavigation(
    Navigation: User.Posts,
    subquery: NavigationExpansionExpression
        Source: DbSet<Post>()
            .Where(p => EF.Property<Nullable<int>>(u, "Id") != null && object.Equals(
                objA: (object)EF.Property<Nullable<int>>(u, "Id"), 
                objB: (object)EF.Property<Nullable<int>>(p, "AuthorId")))
        PendingSelector: p => NavigationTreeExpression
            Value: EntityReference: Post
            Expression: p
        .Where(i => EF.Property<Nullable<int>>(NavigationTreeExpression
            Value: EntityReference: User
            Expression: u, "Id") != null && object.Equals(
            objA: (object)EF.Property<Nullable<int>>(NavigationTreeExpression
                Value: EntityReference: User
                Expression: u, "Id"), 
            objB: (object)EF.Property<Nullable<int>>(i, "AuthorId")))
    .AsQueryable()
    .OrderByCommon(__input_0)
    .Take(3)' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to either AsEnumerable(), AsAsyncEnumerable(), ToList(), or ToListAsync(). See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

probably because it's being used in an Expression<> context.

How can I make it work there?


A reproducible project can be found in this repository.

Shoe
  • 74,840
  • 36
  • 166
  • 272
  • Can you elaborate a bit on the error? – Athanasios Kataras Nov 02 '20 at 07:27
  • If you change your `OrderBy` extension method such, that it doesn't do anything, just return the input queryable, do you get the same error? And if it only returns `queryable.Where(x => x.Title)`? If they both work, then it is something you do with the enum. Can you edit the question and show us the relevant parts of what you do in the extension method? – Harald Coppoolse Nov 02 '20 at 07:52
  • @AthanasiosKataras Sure I've added the error in the question. – Shoe Nov 02 '20 at 07:53
  • @HaraldCoppoolse Even changing the extension method to simply return the same queryable I receive the same error. This error only happens in the second example (the one within the expression context). – Shoe Nov 02 '20 at 07:55
  • Well this helps. Apparently your problem has nothing to do with OrderBy / IOrderedQueryable etc. It has to do with the difference between `IQueryable`. and `x.Entities.AsQueryable()`. Consider to edit the question and remove all distracting information. Show us the relevant parts of class `Entity`, especially property `Entity.Entities`. – Harald Coppoolse Nov 02 '20 at 08:31
  • 1
    I'm on mobile ATM, and I'll edit the question later with that code, but the Entity.Entities property is just a list of Entity. It's a lazily loaded navigation property. The fundamental problem, I believe, is with the fact that unless you tell EF Core how to translate your custom extension function it doesn't know how to. This doesn't matter outside of an expression context, but it matters within an expression context where everything in it must be translatable to SQL. – Shoe Nov 02 '20 at 08:51
  • @Shoe in both cases you're using LINQ expressions. The error says that the *second* expression can't be translated to SQL, so either the second expression is different from the original, or the original is used against an IEnumerable<>, not a DbContext – Panagiotis Kanavos Nov 02 '20 at 10:41
  • 1
    @Shoe this means that the important parts of the code are missing - whatever is behind `...` and `something here`. `OrderBy(x => x.Title)` can be translated without any problems, so your code is definitely doing something more complex – Panagiotis Kanavos Nov 02 '20 at 10:42
  • `the Entity.Entities property is just a list of Entity` EF Core entities (or rather DbSet<> instances) *can't* exist outside a DbContext. So what exactly is `Entity.Entities` ? What is `Entity`? Have you tried to implement a generic repository on top of EF? A DbSet is already a Repository, a DbContext is a Unit-of-Work containing the entities used in a specific scenario. They aren't database connections or models – Panagiotis Kanavos Nov 02 '20 at 10:46
  • @Shoe, I think it is a not needed EF Core limitation. They should translate that. Better to create reproducible case and create an issue to EF Core Github site. – Svyatoslav Danyliv Nov 02 '20 at 11:44
  • You approach with an OrderBy extension method accepting and returning an IQueryable should work - please share a full code sample. – Shay Rojansky Nov 02 '20 at 15:52
  • @PanagiotisKanavos The difference is that in the first example the method call is called in a regular C# context. In the second example instead it's called within an `Expression>` delegate, which have very special rules. That's why I was confused as to your need to see the `Entities` property. It wouldn't work even if you used any `DbSet`. – Shoe Nov 02 '20 at 16:11
  • Anyway, I've edited it to use a `DbSet` so we don't include information that is not needed (like the shape of `Entity` or `OtherEntities`). The error is the same. I've just tested it again. – Shoe Nov 02 '20 at 16:13
  • @Shoe there is no difference as far as OrderBy is concerned. LINQ to Entities uses expressions everywhere. In the second case though you're trying to mix up two different entities (with some really weird names) instead of using proper navigational properties or even JOINs. What kind of SQL statement should the second example produce? How could `Entities` and `OtherEntities` be combined in the same SQL query? – Panagiotis Kanavos Nov 02 '20 at 19:49
  • In short, this has nothing to do with sorting. The problem is the entire `EntitiesSubset = dbContext.Entities.Where(...)...` query. What are you trying to do? Emulate a SQL subquery? For starters, LINQ isn't a SQL replacement, it's a query language used on top of an ORM for some *very* specific access patterns. If you have to write such a complicated query you shouldn't be using an ORM in the first place. It would be better to create a view or function that wraps the query you want and map the results to entities – Panagiotis Kanavos Nov 02 '20 at 19:55
  • @PanagiotisKanavos Maybe I'm not explaining myself correctly. The example is just an example. Isn't it true that within an expression context (eg. `Expression> expr = (e => );`) you can only use methods/functions for which there exists a `IMethodCallTranslator` registered? See [here](https://www.thinktecture.com/en/entity-framework-core/custom-functions-using-imethodcalltranslator-in-2-1/) as an example of a way to implement that for a custom function. – Shoe Nov 02 '20 at 21:45
  • You can only call methods and functions that are in the [supported list](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/ef/language-reference/supported-and-unsupported-linq-methods-linq-to-entities). My extension method is not supported to be translated to SQL. That's why there's the error there. It has nothing to do with the example per se. – Shoe Nov 02 '20 at 21:54
  • 1
    @PanagiotisKanavos I've improved the question and added a reproducible project. – Shoe Nov 03 '20 at 08:19
  • The problem is crystal clear to anyone who ever tried dealing with query expression trees and reusability. Unfortunately no standard/easy solution exists. – Ivan Stoev Nov 03 '20 at 08:33

1 Answers1

2

This is well known problem with no general solution.

The general problem with expression tree translation is that it is based purely on knowledge - no methods are actually called, the known methods are identified by signature and translated according to their known semantic. That's why custom methods/properties/delegates cannot be translated.

The problem is usually solved by using some expression manipulation library. During my work with EF6/EF Core I've tried many - LinqKit, NeinLinq, AutoMapper, recently DelegateDecompiler. All they allow replacing (expanding) parts of the expression tree with their corresponding raw expressions like if you have written them manually.

The problem in this particular case is more complicate because in order to be translated, the custom method must actually be invoked. But how? Especially, what would be the IQueryble argument? Note that here

x.Posts.AsQueryable()

you don't have x instance, hence no Posts collection instance to call AsQueryable() and pass it to the custom method.

One possible solution is to call the method passing fake LINQ to Objects IQueryable, then finding and replacing it in the resulting query expression tree with the actual expression.

Following is the implementation of the above idea:

partial class IQueryableExtensions
{ 
    public static IQueryable<T> Transform<T>(this IQueryable<T> source)
    {
        var expression = new QueryableMethodTransformer().Visit(source.Expression);
        return expression == source.Expression ? source : source.Provider.CreateQuery<T>(expression);
    }

    class QueryableMethodTransformer : ExpressionVisitor
    {
        protected override Expression VisitMethodCall(MethodCallExpression node)
        {
            if (node.Method.DeclaringType == typeof(IQueryableExtensions) &&
                node.Method.IsStatic &&
                typeof(IQueryable).IsAssignableFrom(node.Method.ReturnType) &&
                node.Arguments.Count > 1 &&
                node.Arguments[0].Type.IsGenericType &&
                node.Arguments[0].Type.GetGenericTypeDefinition() == typeof(IQueryable<>))
            {
                // Extract arguments
                var args = new object[node.Arguments.Count];
                int index = 1;
                while (index < args.Length && TryExtractValue(Visit(node.Arguments[index]), out args[index]))
                    index++;
                if (index == args.Length)
                {
                    var source = node.Arguments[0];
                    var elementType = source.Type.GetGenericArguments()[0];
                    // Create fake queryable instance
                    var fakeSource = args[0] = EmptyQueryableMethod
                        .MakeGenericMethod(elementType)
                        .Invoke(null, null);
                    // Invoke the method with it
                    var result = (IQueryable)node.Method.Invoke(null, args);
                    // Replace it with the actual queryable expression
                    return new ConstValueReplacer
                    {
                        From = fakeSource,
                        To = source
                    }.Visit(result.Expression);
                }
            }
            return base.VisitMethodCall(node);
        }

        static IQueryable<T> EmptyQueryable<T>() => Enumerable.Empty<T>().AsQueryable();

        static readonly MethodInfo EmptyQueryableMethod = typeof(QueryableMethodTransformer)
            .GetMethod(nameof(EmptyQueryable), BindingFlags.NonPublic | BindingFlags.Static);

        static bool TryExtractValue(Expression source, out object value)
        {
            if (source is ConstantExpression constExpr)
            {
                value = constExpr.Value;
                return true;
            }
            if (source is MemberExpression memberExpr && TryExtractValue(memberExpr.Expression, out var instance))
            {
                value = memberExpr.Member is FieldInfo field ? field.GetValue(instance) :
                    ((PropertyInfo)memberExpr.Member).GetValue(instance);
                return true;
            }
            value = null;
            return source == null;
        }
    }

    class ConstValueReplacer : ExpressionVisitor
    {
        public object From;
        public Expression To;
        protected override Expression VisitConstant(ConstantExpression node) =>
            node.Value == From ? To : base.VisitConstant(node);
    }
}

As one can see, it is not very generic because it has a lot of assumptions - finds a static method taking first IQueryable<T> argument and other arguments being evaluatable (constant values or field/properties of constant values, which is the case with closures) and performs the aforementioned action.

But it solves the particular issue. All you need is to call Transform at the end of you query (before materialization):

var users = dbContext.Users
    .Select(x => new
    {
        User = x,
        Top3Posts = x.Posts.AsQueryable()
            .OrderByCommon(input)
            .Take(3)
            .ToList()
    })
    .Transform() // <--
    .ToList();

Now, it's possible to avoid the need of Transform call by plugging the QueryableMethodTransformer into EF Core query translation pipeline, but it requires a lot of plumbing code just to call a single method. Note that it has to be plugged into query pretranslator, since IMethodCallTranslator cannot process IQueryable (and in general IEnumerable) arguments. If you are interested, my answer to EF Core queries all columns in SQL when mapping to object in Select shows how you could plug DelegateDecompiler into EF Core, the same code can literally be used to plug aby other (including the one presented here) custom expression visitor based preprocessor.

Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343