Bestelllogik wiederverwenden

Nov 02 2020

Ich habe eine Aufzählung, die eine bestimmte Sortierreihenfolge für einen Beitrag beschreibt:

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

und eine Erweiterungsmethode zur Wiederverwendung der Bestelllogik:

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(),
        };
}

Die Erweiterungsmethode funktioniert in einem normalen Kontext, schlägt jedoch hier fehl:

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();

mit diesem Fehler:

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.

wahrscheinlich, weil es in einem Expression<>Kontext verwendet wird.

Wie kann ich es dort zum Laufen bringen?


Ein reproduzierbares Projekt finden Sie in diesem Repository .

Antworten

1 IvanStoev Nov 03 2020 at 19:37

Dies ist ein bekanntes Problem ohne allgemeine Lösung.

Das allgemeine Problem bei der Übersetzung von Ausdrucksbäumen besteht darin, dass sie ausschließlich auf Wissen basiert - es werden keine Methoden aufgerufen, die bekannten Methoden werden durch Signatur identifiziert und gemäß ihrer bekannten Semantik übersetzt. Aus diesem Grund können benutzerdefinierte Methoden / Eigenschaften / Delegaten nicht übersetzt werden.

Das Problem wird normalerweise durch Verwendung einer Ausdrucksmanipulationsbibliothek gelöst. Während meiner Arbeit mit EF6 / EF Core habe ich viele ausprobiert - LinqKit, NeinLinq, AutoMapper, kürzlich DelegateDecompiler. Sie ermöglichen es, Teile des Ausdrucksbaums durch die entsprechenden Rohausdrücke zu ersetzen (zu erweitern), als hätten Sie sie manuell geschrieben.

Das Problem in diesem speziellen Fall ist komplizierter, da die benutzerdefinierte Methode tatsächlich aufgerufen werden muss, um übersetzt zu werden. Aber wie? Was wäre das IQuerybleArgument? Beachten Sie das hier

x.Posts.AsQueryable()

Sie haben keine xInstanz, daher keine PostsAuflistungsinstanz, die aufgerufen AsQueryable()und an die benutzerdefinierte Methode übergeben werden kann.

Eine mögliche Lösung besteht darin, die Methode aufzurufen, die gefälschten LINQ an Objekte übergibt IQueryable, und ihn dann im resultierenden Abfrageausdrucksbaum zu finden und durch den tatsächlichen Ausdruck zu ersetzen.

Es folgt die Umsetzung der obigen Idee:

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);
    }
}

Wie man sehen kann, ist es nicht sehr allgemein gehalten, da es viele Annahmen hat - findet eine statische Methode, die das erste IQueryable<T>Argument verwendet, und andere Argumente, die auswertbar sind (konstante Werte oder Feld / Eigenschaften von konstanten Werten, was bei Abschlüssen der Fall ist) und führt aus die vorgenannte Aktion.

Aber es löst das spezielle Problem. Alles, was Sie brauchen, ist, Transformam Ende Ihrer Abfrage (vor der Materialisierung) aufzurufen :

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

Jetzt ist es möglich, die Notwendigkeit eines TransformAufrufs zu vermeiden, indem Sie die QueryableMethodTransformerEF Core-Abfrageübersetzungspipeline anschließen. Es ist jedoch viel Installationscode erforderlich, um nur eine einzige Methode aufzurufen. Beachten Sie, dass es in den Abfrage-Vorübersetzer eingesteckt werden muss, da IMethodCallTranslatores keine IQueryable(und im Allgemeinen IEnumerable) Argumente verarbeiten kann. Wenn Sie interessiert sind, zeigt meine Antwort auf EF Core, dass alle Spalten in SQL beim Zuordnen zu einem Objekt in Select abgefragt werden, wie Sie DelegateDecompiler in EF Core einbinden können. Derselbe Code kann buchstäblich verwendet werden, um andere (einschließlich des hier vorgestellten) Benutzerdefinierten einzufügen Ausdruck Besucherbasierter Präprozessor.