Consulta basada en el estado en tiempo de ejecución

En la mayoría de las consultas LINQ, la forma general de la consulta se establece en el código. Puede filtrar elementos mediante una cláusula where, ordenar la colección de salida mediante orderby, elementos de grupo o realizar algún cálculo. El código puede proporcionar parámetros para el filtro, la clave de ordenación u otras expresiones que forman parte de la consulta. Sin embargo, la forma general de la consulta no puede cambiar. En este artículo, aprenderá técnicas para usar la interfaz System.Linq.IQueryable<T> y los tipos que lo implementan para modificar la forma de una consulta en tiempo de ejecución.

Estas técnicas se usan para crear consultas en tiempo de ejecución, donde algunos datos de entrada de usuario o estado en tiempo de ejecución cambian los métodos de consulta que desea usar como parte de la consulta. Quiere editar la consulta al agregar, quitar o modificar cláusulas de consulta.

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 una interfaz 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"
];

// Use 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 el código anterior, se ejecuta la misma consulta exacta. Vamos a aprender a modificar la consulta para ampliarla o modificarla. Fundamentalmente, una interfaz IQueryable tiene dos componentes:

  • Expression: representación independiente del lenguaje y del origen de datos de los componentes de la consulta actual, en forma de un á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 sigue siendo el mismo; el árbol de expresión de la consulta varía entre consultas.

Los árboles de expresión son inmutables; si quiere otro árbol de expresión (y, por tanto, otra consulta), tendrá que convertir el existente en uno nuevo. 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
  • Llamar a más métodos LINQ
  • 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 los métodos de generador de 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

Cada una de las técnicas permite más funcionalidades, pero a un costo de mayor complejidad.

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

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.

Llamar a más métodos LINQ

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 System.Linq.IQueryable<T> para obtener una nueva consulta. Puede usar el estado en tiempo 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 con otra biblioteca, 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 conoce el tipo de elemento en tiempo de compilación (string) y, por tanto, el tipo de la consulta (IQueryable<string>). Es posible que agregue componentes a una consulta de cualquier tipo de elemento o agregue 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 expresión<TDelegate>

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

System.Linq.Expressions.Expression<TDelegate> hereda de LambdaExpression, que representa una expresión lambda completa como el siguiente ejemplo:

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

LambdaExpression tiene dos componentes:

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

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

  1. 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 = Parameter(typeof(string), "x");
    
  2. Construya el cuerpo de LambdaExpression utilizando los valores ParameterExpression definidos y los métodos Factory Method 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", [typeof(string)])!,
        Constant("a")
    );
    
  3. 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 desee construir una Expression<TDelegate> para pasarla a un método LINQ. Proporciona un ejemplo completo de cómo hacerlo mediante los métodos de fábrica.

Construcción de una consulta completa en tiempo de ejecución

Quiere escribir consultas que funcionen con 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.

// 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", [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 interfaz IQueryable<T> (y no solo una interfaz IQueryable), puede agregar más elementos de consulta con tipo de tiempo de compilación después del filtro de 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);

Agregar nodos de llamada de método al árbol de expresiones de IQueryable<TDelegate>

Si tiene una interfaz IQueryable en lugar de IQueryable<T>, no puede llamar directamente a los métodos de LINQ genéricos. Una alternativa consiste en crear el árbol de expresión interno como se ha indicado en el ejemplo anterior 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",
        [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 lugar de una 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);
}