Réutilisation de la logique de commande

Nov 02 2020

J'ai une énumération décrivant un certain ordre de tri pour un article:

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

et une méthode d'extension pour réutiliser la logique de commande:

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

La méthode d'extension fonctionne lorsqu'elle est utilisée dans un contexte normal mais échoue ici:

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

avec cette erreur:

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.

probablement parce qu'il est utilisé dans un Expression<>contexte.

Comment puis-je le faire fonctionner là-bas?


Un projet reproductible peut être trouvé dans ce référentiel .

Réponses

1 IvanStoev Nov 03 2020 at 19:37

Il s'agit d'un problème bien connu sans solution générale.

Le problème général de la traduction d'arbre d'expression est qu'elle est basée uniquement sur la connaissance - aucune méthode n'est réellement appelée, les méthodes connues sont identifiées par signature et traduites selon leur sémantique connue. C'est pourquoi les méthodes / propriétés / délégués personnalisés ne peuvent pas être traduits.

Le problème est généralement résolu en utilisant une bibliothèque de manipulation d'expression. Au cours de mon travail avec EF6 / EF Core, j'en ai essayé beaucoup - LinqKit, NeinLinq, AutoMapper, récemment DelegateDecompiler. Tout cela permet de remplacer (développer) des parties de l'arborescence d'expression par leurs expressions brutes correspondantes, comme si vous les aviez écrites manuellement.

Le problème dans ce cas particulier est plus compliqué car pour être traduit, la méthode personnalisée doit en fait être appelée. Mais comment? Surtout quel serait l' IQuerybleargument? Notez qu'ici

x.Posts.AsQueryable()

vous n'avez pas d' xinstance, donc aucune Postsinstance de collection à appeler AsQueryable()et à la transmettre à la méthode personnalisée.

Une solution possible consiste à appeler la méthode en passant de faux LINQ à Objects IQueryable, puis à la rechercher et à la remplacer dans l'arborescence d'expression de requête résultante par l'expression réelle.

Voici la mise en œuvre de l'idée ci-dessus:

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

Comme on peut le voir, ce n'est pas très générique car il a beaucoup d'hypothèses - trouve une méthode statique prenant le premier IQueryable<T>argument et d'autres arguments pouvant être évalués (valeurs constantes ou champ / propriétés de valeurs constantes, ce qui est le cas avec les fermetures) et effectue l'action susmentionnée.

Mais cela résout le problème particulier. Il vous suffit d'appeler Transformà la fin de votre requête (avant la matérialisation):

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

Désormais, il est possible d'éviter le besoin d' Transformappel en branchant le QueryableMethodTransformerpipeline de traduction de requête EF Core, mais cela nécessite beaucoup de code de plomberie juste pour appeler une seule méthode. Notez qu'il doit être branché sur le prétranslateur de requête, car IMethodCallTranslatoril ne peut pas traiter IQueryable(et en général IEnumerable) les arguments. Si vous êtes intéressé, ma réponse à EF Core interroge toutes les colonnes dans SQL lors du mappage à un objet dans Select montre comment vous pouvez brancher DelegateDecompiler dans EF Core, le même code peut littéralement être utilisé pour brancher un autre (y compris celui présenté ici) personnalisé préprocesseur basé sur le visiteur d'expression.