Consultas basadas en el estado del entorno de ejecución (C#)

Nota

Asegúrese de agregar using System.Linq.Expressions; y using static System.Linq.Expressions.Expression; en la parte superior del archivo de .cs.

Tenga en cuenta el código que define IQueryable o IQueryable<T> con respecto a un origen de datos:

string[] companyNames = [
    "Consolidated Messenger", "Alpine Ski House", "Southridge Video",
    "City Power & Light", "Coho Winery", "Wide World Importers",
    "Graphic Design Institute", "Adventure Works", "Humongous Insurance",
    "Woodgrove Bank", "Margie's Travel", "Northwind Traders",
    "Blue Yonder Airlines", "Trey Research", "The Phone Company",
    "Wingtip Toys", "Lucerne Publishing", "Fourth Coffee"
];

// We're using an in-memory array as the data source, but the IQueryable could have come
// from anywhere -- an ORM backed by a database, a web request, or any other LINQ provider.
IQueryable<string> companyNamesSource = companyNames.AsQueryable();
var fixedQry = companyNames.OrderBy(x => x);

Cada vez que ejecute este código, se ejecutará la misma consulta exacta. Esto no suele ser muy útil, ya que es posible que quiera que el código ejecute otras consultas en función de las condiciones en tiempo de ejecución. En este artículo se describe cómo puede ejecutar otra consulta en función del estado del entorno de ejecución.

IQueryable o IQueryable<T> y árboles de expresiones

Fundamentalmente, una interfaz IQueryable tiene dos componentes:

  • Expression, una representación, independiente del lenguaje y del origen de datos, de los componentes de la consulta actual, en forma de árbol de expresión.
  • Provider, una instancia de un proveedor LINQ, que sabe cómo materializar la consulta actual en un valor o en un conjunto de valores.

En el contexto de las consultas dinámicas, el proveedor normalmente seguirá siendo el mismo; el árbol de expresión de la consulta variará entre consultas.

Los árboles de expresión son inmutables; si quieres otro árbol de expresión (y, por tanto, otra consulta), tendrás que convertir el existente en uno nuevo y, por tanto, en una nueva instancia de IQueryable.

En las secciones siguientes se describen técnicas específicas para realizar consultas de forma diferente en respuesta al estado del entorno de ejecución:

  • Uso del estado del entorno de ejecución desde el árbol de expresión
  • Llamada a métodos de LINQ adicionales
  • Variación del árbol de expresión que se pasa a los métodos de LINQ
  • Creación de un árbol de expresión Expression<TDelegate> con Factory Methods en Expression
  • Adición de nodos de llamada de método al árbol de expresión de IQueryable
  • Construcción de cadenas y uso de la biblioteca dinámica de LINQ

Uso del estado del entorno de ejecución desde el árbol de expresión

Siempre que el proveedor LINQ lo admita, la manera más sencilla de realizar consultas dinámicas consiste en hacer referencia al estado del entorno de ejecución de forma directa en la consulta mediante una variable cerrada, como length en el ejemplo de código siguiente:

var length = 1;
var qry = companyNamesSource
    .Select(x => x.Substring(0, length))
    .Distinct();

Console.WriteLine(string.Join(",", qry));
// prints: C, A, S, W, G, H, M, N, B, T, L, F

length = 2;
Console.WriteLine(string.Join(",", qry));
// prints: Co, Al, So, Ci, Wi, Gr, Ad, Hu, Wo, Ma, No, Bl, Tr, Th, Lu, Fo

El árbol de expresión interno (y, por tanto, la consulta) no se han modificado; la consulta solo devuelve otros valores porque se ha cambiado el valor de length.

Llamada a métodos de LINQ adicionales

Por lo general, los métodos de LINQ integrados en Queryable realizan dos pasos:

  • Encapsulan el árbol de expresión actual en un elemento MethodCallExpression que representa la llamada de método.
  • Vuelven a pasar el árbol de expresión encapsulado al proveedor, ya sea para devolver un valor mediante el método IQueryProvider.Execute del proveedor, o bien para devolver un objeto de consulta traducido mediante el método IQueryProvider.CreateQuery.

Puede reemplazar la consulta original con el resultado de un método que devuelva IQueryable<T> para obtener una nueva consulta. Puede hacerlo condicionalmente en función del estado del entorno de ejecución, como en el ejemplo siguiente:

// bool sortByLength = /* ... */;

var qry = companyNamesSource;
if (sortByLength)
{
    qry = qry.OrderBy(x => x.Length);
}

Variación del árbol de expresión que se pasa a los métodos de LINQ

Puede pasar otras expresiones a los métodos de LINQ, en función del estado del entorno de ejecución:

// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>> expr = (startsWith, endsWith) switch
{
    ("" or null, "" or null) => x => true,
    (_, "" or null) => x => x.StartsWith(startsWith),
    ("" or null, _) => x => x.EndsWith(endsWith),
    (_, _) => x => x.StartsWith(startsWith) || x.EndsWith(endsWith)
};

var qry = companyNamesSource.Where(expr);

También es posible que quiera crear las distintas subexpresiones mediante una biblioteca de terceros, como PredicateBuilder de LinqKit:

// This is functionally equivalent to the previous example.

// using LinqKit;
// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>>? expr = PredicateBuilder.New<string>(false);
var original = expr;
if (!string.IsNullOrEmpty(startsWith))
{
    expr = expr.Or(x => x.StartsWith(startsWith));
}
if (!string.IsNullOrEmpty(endsWith))
{
    expr = expr.Or(x => x.EndsWith(endsWith));
}
if (expr == original)
{
    expr = x => true;
}

var qry = companyNamesSource.Where(expr);

Creación de árboles de expresión y consultas mediante métodos de generador

En todos los ejemplos vistos hasta ahora, se ha conocido el tipo de elemento en tiempo de compilación (string) y por tanto el tipo de la consulta (IQueryable<string>). Es posible que necesite agregar componentes a una consulta de cualquier tipo de elemento o agregar componentes diferentes, en función del tipo de elemento. Puede crear árboles de expresión desde cero, con los métodos de generador de System.Linq.Expressions.Expression y, por tanto, adaptar la expresión en tiempo de ejecución a un tipo de elemento específico.

Creación de una instancia de Expression<TDelegate>

Cuando se crea una expresión para pasarla a uno de los métodos LINQ, en realidad se crea una instancia de Expression<TDelegate>, en la que TDelegate es un tipo de delegado como Func<string, bool>, Action o un tipo de delegado personalizado.

Expression<TDelegate> hereda de LambdaExpression, que representa una expresión lambda completa como la siguiente:

Expression<Func<string, bool>> expr = x => x.StartsWith("a");

LambdaExpression tiene dos componentes:

  • Una lista de parámetros ((string x)) representada por la propiedad Parameters.
  • Un cuerpo (x.StartsWith("a")) representado por la propiedad Body.

Los pasos básicos para crear una instancia de Expression<TDelegate> son los siguientes:

  • Defina objetos ParameterExpression para cada uno de los parámetros (si existen) de la expresión lambda, mediante el método generador Parameter.

    ParameterExpression x = Expression.Parameter(typeof(string), "x");
    
  • Construya el cuerpo de LambdaExpression utilizando los valores ParameterExpression definidos por el usuario y los métodos de generador en Expression. Por ejemplo, una expresión que represente x.StartsWith("a") se podría crear de la siguiente manera:

    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!,
        Constant("a")
    );
    
  • Ajuste los parámetros y el cuerpo en una instancia de Expression<TDelegate> con tipo de tiempo de compilación, mediante la sobrecarga apropiada de Factory Method Lambda:

    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

En las secciones siguientes se describe un escenario en el que es posible que quiera crear una instancia de Expression<TDelegate> para pasarla a un método LINQ, y se proporciona un ejemplo completo de cómo hacerlo mediante Factory Methods.

Escenario

Imagine que tiene varios tipos de entidad:

record Person(string LastName, string FirstName, DateTime DateOfBirth);
record Car(string Model, int Year);

En cualquiera de estos tipos de entidad, quiere filtrar y devolver solo las entidades que contengan un texto concreto dentro de uno de sus campos string. Para Person, le interesa buscar las propiedades FirstName y LastName:

string term = /* ... */;
var personsQry = new List<Person>()
    .AsQueryable()
    .Where(x => x.FirstName.Contains(term) || x.LastName.Contains(term));

Pero para Car, solo quiere buscar la propiedad Model:

string term = /* ... */;
var carsQry = new List<Car>()
    .AsQueryable()
    .Where(x => x.Model.Contains(term));

Aunque podría escribir una función personalizada para IQueryable<Person> y otra para IQueryable<Car>, la siguiente función agrega este filtrado a cualquier consulta existente, con independencia del tipo de elemento específico.

Ejemplo

// using static System.Linq.Expressions.Expression;

IQueryable<T> TextFilter<T>(IQueryable<T> source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    // T is a compile-time placeholder for the element type of the query.
    Type elementType = typeof(T);

    // Get all the string properties on this specific type.
    PropertyInfo[] stringProperties =
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Get the right overload of String.Contains
    MethodInfo containsMethod = typeof(string).GetMethod("Contains", new[] { typeof(string) })!;

    // Create a parameter for the expression tree:
    // the 'x' in 'x => x.PropertyName.Contains("term")'
    // The type of this parameter is the query's element type
    ParameterExpression prm = Parameter(elementType);

    // Map each property to an expression tree node
    IEnumerable<Expression> expressions = stringProperties
        .Select(prp =>
            // For each property, we have to construct an expression tree node like x.PropertyName.Contains("term")
            Call(                  // .Contains(...) 
                Property(          // .PropertyName
                    prm,           // x 
                    prp
                ),
                containsMethod,
                Constant(term)     // "term" 
            )
        );

    // Combine all the resultant expression nodes using ||
    Expression body = expressions
        .Aggregate(
            (prev, current) => Or(prev, current)
        );

    // Wrap the expression body in a compile-time-typed lambda expression
    Expression<Func<T, bool>> lambda = Lambda<Func<T, bool>>(body, prm);

    // Because the lambda is compile-time-typed (albeit with a generic parameter), we can use it with the Where method
    return source.Where(lambda);
}

Como la función TextFilter toma y devuelve una instancia de IQueryable<T> (y no solo IQueryable), puede agregar más elementos de consulta con tipo de tiempo de compilación después del filtro del texto.

var qry = TextFilter(
        new List<Person>().AsQueryable(), 
        "abcd"
    )
    .Where(x => x.DateOfBirth < new DateTime(2001, 1, 1));

var qry1 = TextFilter(
        new List<Car>().AsQueryable(), 
        "abcd"
    )
    .Where(x => x.Year == 2010);

Adición de nodos de llamada de método al árbol de expresión de IQueryable

Si tiene IQueryable en lugar de IQueryable<T>, no puede llamar directamente a los métodos LINQ genéricos. Una alternativa consiste en crear el árbol de expresión interno como se ha indicado antes y usar la reflexión para invocar el método de LINQ adecuado mientras se pasa el árbol de expresión.

También puede duplicar la función del método de LINQ y encapsular todo el árbol en un elemento MethodCallExpression que represente una llamada al método de LINQ:

IQueryable TextFilter_Untyped(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }
    Type elementType = source.ElementType;

    // The logic for building the ParameterExpression and the LambdaExpression's body is the same as in the previous example,
    // but has been refactored into the constructBody function.
    (Expression? body, ParameterExpression? prm) = constructBody(elementType, term);
    if (body is null) {return source;}

    Expression filteredTree = Call(
        typeof(Queryable),
        "Where",
        new[] { elementType},
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

En este caso no tiene un marcador de posición genérico T en tiempo de compilación, por lo que usará la sobrecarga de Lambda, que no necesita información de tipos de tiempo de compilación, y que genera un elemento LambdaExpression en vez de una instancia de Expression<TDelegate>.

Biblioteca dinámica de LINQ

La creación de árboles de expresión mediante métodos de generador es relativamente compleja; es más fácil crear cadenas. La biblioteca dinámica de LINQ expone un conjunto de métodos de extensión en IQueryable correspondiente a los métodos estándar de LINQ en Queryable y que aceptan cadenas en una sintaxis especial en lugar de árboles de expresión. La biblioteca genera el árbol de expresión adecuado a partir de la cadena y puede devolver la interfaz IQueryable traducida resultante.

El ejemplo anterior se podría volver a escribir de esta manera:

// using System.Linq.Dynamic.Core

IQueryable TextFilter_Strings(IQueryable source, string term) {
    if (string.IsNullOrEmpty(term)) { return source; }

    var elementType = source.ElementType;

    // Get all the string property names on this specific type.
    var stringProperties = 
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Build the string expression
    string filterExpr = string.Join(
        " || ",
        stringProperties.Select(prp => $"{prp.Name}.Contains(@0)")
    );

    return source.Where(filterExpr, term);
}

Vea también