Database
 sql >> Database >  >> RDS >> Database

Utilizzo di espressioni per filtrare i dati del database

Vorrei iniziare con una descrizione del problema che ho riscontrato. Ci sono entità nel database che devono essere visualizzate come tabelle nell'interfaccia utente. Entity Framework viene utilizzato per accedere al database. Sono disponibili filtri per queste colonne della tabella.

È necessario scrivere un codice per filtrare le entità in base ai parametri.

Ad esempio, ci sono due entità:Utente e Prodotto.

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Supponiamo di dover filtrare utenti e prodotti per nome. Creiamo metodi per filtrare ogni entità.

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return users.Where(user => user.Name.Contains(text));
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return products.Where(product => product.Name.Contains(text));
}

Come puoi vedere, questi due metodi sono quasi identici e differiscono solo per la proprietà dell'entità, in base alla quale filtra i dati.

Potrebbe essere una sfida se abbiamo dozzine di entità con dozzine di campi che richiedono il filtraggio. La complessità risiede nel supporto del codice, nella copia sconsiderata e, di conseguenza, nello sviluppo lento e nell'elevata probabilità di errore.

Parafrasando Fowler, inizia a puzzare. Vorrei scrivere qualcosa di standard invece della duplicazione del codice. Ad esempio:

public IQueryable<User> FilterUsersByName(IQueryable<User> users, string text)
{
    return FilterContainsText(users, user => user.Name, text);
}

public IQueryable<Product> FilterProductsByName(IQueryable<Product> products, string text)
{
    return FilterContainsText(products, propduct => propduct.Name, text);
}

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities,
 Func<TEntity, string> getProperty, string text)
{
    return entities.Where(entity => getProperty(entity).Contains(text));
}

Sfortunatamente, se proviamo a filtrare:

public void TestFilter()
{
    using (var context = new Context())
    {
            var filteredProducts = FilterProductsByName(context.Products, "name").ToArray();
    }
}

Otterremo l'errore «Metodo di test ExpressionTests.ExpressionTest.TestFilter ha generato l'eccezione:
System.NotSupportedException :il tipo di nodo dell'espressione LINQ 'Invoke' non è supportato in LINQ alle entità.

Espressioni

Controlliamo cosa è andato storto.

Il metodo Where accetta un parametro del tipo Expression>. Pertanto, Linq lavora con gli alberi delle espressioni, mediante i quali crea query SQL, anziché con i delegati.

L'espressione descrive un albero della sintassi. Per capire meglio come sono strutturati, considera l'espressione, che controlla che un nome sia uguale a una riga.

Expression<Func<Product, bool>> expected = product => product.Name == "target";

Durante il debug, possiamo vedere la struttura di questa espressione (le proprietà della chiave sono contrassegnate in rosso).

Abbiamo il seguente albero:

Quando si passa un delegato come parametro, viene generato un albero diverso, che chiama il metodo Invoke sul parametro (delegato) invece di invocare la proprietà dell'entità.

Quando Linq sta tentando di creare una query SQL tramite questo albero, non sa come interpretare il metodo Invoke e genera NotSupportedException.

Pertanto, il nostro compito è sostituire il cast alla proprietà dell'entità (la parte dell'albero contrassegnata in rosso) con l'espressione che viene passata tramite questo parametro.

Proviamo:

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter(product) == "target"

Ora possiamo vedere l'errore «Nome metodo previsto» in fase di compilazione.

Il problema è che un'espressione è una classe che rappresenta i nodi di un albero della sintassi, anziché il delegato e non può essere chiamata direttamente. Ora, il compito principale è trovare un modo per creare un'espressione passandogli un altro parametro.

Il visitatore

Dopo una breve ricerca su Google, ho trovato una soluzione per il problema simile su StackOverflow.

Per lavorare con le espressioni, esiste la classe ExpressionVisitor, che utilizza il pattern Visitor. È progettato per attraversare tutti i nodi dell'albero delle espressioni nell'ordine di analisi dell'albero della sintassi e consente di modificarli o restituire invece un altro nodo. Se né il nodo né i suoi nodi figlio vengono modificati, viene restituita l'espressione originale.

Quando ereditiamo dalla classe ExpressionVisitor, possiamo sostituire qualsiasi nodo dell'albero con l'espressione, che passiamo tramite il parametro. Pertanto, dobbiamo inserire nell'albero un'etichetta di nodo, che sostituiremo con un parametro. Per fare ciò, scrivi un metodo di estensione che simuli la chiamata dell'espressione e sia un marker.

public static class ExpressionExtension
{
    public static TFunc Call<TFunc>(this Expression<TFunc> expression)
    {
        throw new InvalidOperationException("This method should never be called. It is a marker for replacing.");
    }
}

Ora possiamo sostituire un'espressione con un'altra

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product) == "target";

È necessario scrivere un visitatore, che sostituirà il metodo Call con il suo parametro nell'albero delle espressioni:

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion =
            typeof(ExpressionExtension).GetMethod(nameof(ExpressionExtension.Call)).GetGenericMethodDefinition();
    }

    protected override Expression VisitMethodCall(MethodCallExpression node)
    {
        if (IsMarker(node))
        {
            return Visit(ExtractExpression(node));
        }
        return base.VisitMethodCall(node);
    }

    private LambdaExpression ExtractExpression(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod && node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Possiamo sostituire il nostro pennarello:

public static Expression<TFunc> SubstituteMarker<TFunc>(this Expression<TFunc> expression)
{
    var visitor = new SubstituteExpressionCallVisitor();
    return (Expression<TFunc>)visitor.Visit(expression);
}

Expression<Func<Product, string>> propertyGetter = product => product.Name;
Expression<Func<Product, bool>> filter = product => propertyGetter.Call()(product).Contains("123");
Expression<Func<Product, bool>> finalFilter = filter.SubstituteMarker();

Durante il debug, possiamo vedere che l'espressione non è quella che ci aspettavamo. Il filtro contiene ancora il metodo Invoke.

Il fatto è che le espressioni parameterGetter e finalFilter utilizzano due argomenti diversi. Pertanto, è necessario sostituire un argomento in parameterGetter con l'argomento in finalFilter. Per fare ciò, creiamo un altro visitatore:

Il risultato è il seguente:

public class SubstituteParameterVisitor : ExpressionVisitor
{
    private readonly LambdaExpression _expressionToVisit;
    private readonly Dictionary<ParameterExpression, Expression> _substitutionByParameter;

    public SubstituteParameterVisitor(Expression[] parameterSubstitutions, LambdaExpression expressionToVisit)
    {
        _expressionToVisit = expressionToVisit;
        _substitutionByParameter = expressionToVisit
                .Parameters
                .Select((parameter, index) => new {Parameter = parameter, Index = index})
                .ToDictionary(pair => pair.Parameter, pair => parameterSubstitutions[pair.Index]);
    }

    public Expression Replace()
    {
        return Visit(_expressionToVisit.Body);
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        Expression substitution;
        if (_substitutionByParameter.TryGetValue(node, out substitution))
        {
            return Visit(substitution);
        }
        return base.VisitParameter(node);
    }
}

public class SubstituteExpressionCallVisitor : ExpressionVisitor
{
    private readonly MethodInfo _markerDesctiprion;

    public SubstituteExpressionCallVisitor()
    {
        _markerDesctiprion = typeof(ExpressionExtensions)
            .GetMethod(nameof(ExpressionExtensions.Call))
            .GetGenericMethodDefinition();
    }

    protected override Expression VisitInvocation(InvocationExpression node)
    {
        var isMarkerCall = node.Expression.NodeType == ExpressionType.Call &&
                           IsMarker((MethodCallExpression) node.Expression);
        if (isMarkerCall)
        {
            var parameterReplacer = new SubstituteParameterVisitor(node.Arguments.ToArray(),
                Unwrap((MethodCallExpression) node.Expression));
            var target = parameterReplacer.Replace();
            return Visit(target);
        }
        return base.VisitInvocation(node);
    }

    private LambdaExpression Unwrap(MethodCallExpression node)
    {
        var target = node.Arguments[0];
        return (LambdaExpression)Expression.Lambda(target).Compile().DynamicInvoke();
    }

    private bool IsMarker(MethodCallExpression node)
    {
        return node.Method.IsGenericMethod &&
               node.Method.GetGenericMethodDefinition() == _markerDesctiprion;
    }
}

Ora tutto funziona come dovrebbe e finalmente possiamo scrivere il nostro metodo di filtraggio

public IQueryable<TEntity> FilterContainsText<TEntity>(IQueryable<TEntity> entities, Expression<Func<TEntity, string>> getProperty, string text)
{
    Expression<Func<TEntity, bool>> filter = entity => getProperty.Call()(entity).Contains(text);
    return entities.Where(filter.SubstituteMarker());
}

Conclusione

L'approccio con la sostituzione dell'espressione può essere utilizzato non solo per il filtraggio ma anche per l'ordinamento e qualsiasi query al database.

Inoltre, questo metodo consente di archiviare le espressioni insieme alla logica aziendale separatamente dalle query al database.

Puoi guardare il codice su GitHub.

Questo articolo si basa su una risposta StackOverflow.