Выполнение запросов на основе состояния среды выполнения (C#)

Рассмотрим код, определяющий IQueryable или IQueryable<T> для источника данных:

var companyNames = new[] {
    "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);

Каждый раз при выполнении этого кода выполняется один и тот же запрос. Зачастую это неэффективно, поскольку может потребоваться, чтобы код выполнял различные запросы в зависимости от условий во время выполнения. В этой статье описывается, как можно выполнить другой запрос на основе состояния среды выполнения.

IQueryable/IQueryable<T> и деревья выражений

В своей основе IQueryable имеет два компонента:

  • Expression—представление компонентов текущего запроса с учетом языка и источника данных в виде дерева выражения.
  • Provider—экземпляр поставщика LINQ, которому известно, как материализовать текущий запрос в значение или набор значений.

В контексте динамических запросов поставщик обычно остается неизменным; дерево выражения запроса будет отличаться от запроса к запросу.

Деревья выражений являются неизменяемыми; если вы хотите использовать другое дерево выражения —и, таким образом, другой запрос—, вам потребуется перевести существующее дерево выражения в новое, а значит в новое IQueryable.

В следующих разделах описываются конкретные методы запроса в зависимости от состояния среды выполнения.

  • Использование состояния среды выполнения из дерева выражений
  • Вызов дополнительных методов LINQ
  • Изменение дерева выражения, переданного в методы LINQ
  • Создайте выражение дерево выражения Expression<TDelegate> с помощью фабричных методов в Expression
  • Добавление узлов вызова метода в дерево выражения IQueryable.
  • Создание строк и использование динамической библиотеки LINQ

Использование состояния среды выполнения из дерева выражений

Предположив, что поставщик LINQ поддерживает его, самый простой способ динамического запроса будет заключаться в ссылке на состояние среды выполнения непосредственно в запросе с помощью закрытой переменной, такой как length в следующем примере кода:

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

Внутреннее дерево выражения, —а соответственно и запрос—, не были изменены; запрос возвращает разные значения только из-за изменения значения length.

Вызов дополнительных методов LINQ

Как правило, встроенные методы LINQ в Queryable выполняют два действия:

  • Заключение текущего дерево выражения в оболочку в MethodCallExpression, который представляет вызов метода.
  • Передача инкапсулированного дерева выражения в поставщик, чтобы вернуть значение через метод IQueryProvider.Execute поставщика, либо чтобы вернуть объект переведенного запроса с помощью метода IQueryProvider.CreateQuery.

Чтобы получить новый запрос, можно заменить исходный запрос на результат метода, возвращающего IQueryable<T>. Это можно сделать по условию на основе состояния среды выполнения, как показано в следующем примере:

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

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

Изменение дерева выражения, переданного в методы LINQ

В методы LINQ можно передать разные выражения в зависимости от состояния среды выполнения:

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

Также может потребоваться составить различные подвыражения, используя сторонние библиотеки, такие как PredicateBuilder от 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);

Создание деревьев выражений и запросов с помощью фабричных методов

Во всех примерах нам известен тип элемента во время компиляции,—string—а соответственно и тип запроса—IQueryable<string>. Может потребоваться добавить компоненты в запрос любого типа элемента или добавить различные компоненты в зависимости от типа элемента. Можно создавать деревья выражений с нуля, используя фабричные методы в System.Linq.Expressions.Expression и таким образом адаптировать выражение в среде выполнения к определенному типу элемента.

Создание выражения<TDelegate>

При создании выражения для его передачи в один из методов LINQ фактически создается экземпляр выражения<TDelegate>, где TDelegate — это некоторый тип делегата, например Func<string, bool>, Action или настраиваемый тип делегата.

Выражение<TDelegate> наследуется от LambdaExpression, которое представляет собой полное лямбда-выражение следующего вида:

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

LambdaExpression имеет два компонента:

  • список параметров,—(string x)—представленных свойством Parameters;
  • тело,—x.StartsWith("a")—представленное свойством Body.

Ниже указаны основные шаги по созданию выражения<TDelegate>.

  • Определите объекты ParameterExpression для каждого из параметров (если таковые имеются) в лямбда-выражении с помощью фабричного метода Parameter.

    ParameterExpression x = Parameter(typeof(string), "x");
    
  • Создайте текст LambdaExpression, используя заданные вами выражения ParameterExpression и фабричные методы в Expression. Например, выражение, представляющее x.StartsWith("a"), может быть построено следующим образом:

    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!,
        Constant("a")
    );
    
  • Заключите параметры и текст в выражение<TDelegate> с типов времени компиляции, используя соответствующую перегрузку фабричного метода Lambda:

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

В следующих разделах описывается сценарий, в котором может потребоваться создать выражение<TDelegate> для передачи в метод LINQ, а также представлен полный пример того, как это сделать с помощью фабричных методов.

Сценарий

Допустим, у вас есть несколько типов сущностей:

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

Для любого из этих типов сущностей необходимо отфильтровать и возвратить только те сущности, которые имеют заданный текст в одном из полей string. Для Person необходимо найти свойства FirstName и LastName:

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

Но для Car требуется найти только свойство Model:

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

Несмотря на то что можно написать одну настраиваемую функцию для IQueryable<Person> и другую для IQueryable<Car>, следующая функция добавляет эту фильтрацию в любой существующий запрос независимо от конкретного типа элемента.

Пример

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

Поскольку функция TextFilter принимает и возвращает IQueryable<T> (а не только IQueryable), после текстового фильтра можно добавить дополнительные элементы запроса с типом времени компиляции.

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

Добавление узлов вызова метода в дерево выражения IQueryable

При наличии IQueryable вместо IQueryable<T> нельзя напрямую вызывать универсальные методы LINQ. В качестве альтернативы можно создать внутреннее дерево выражения, как показано выше, и использовать отражение для вызова соответствующего метода LINQ при передаче в дерево выражения.

Можно также дублировать функциональность метода LINQ, заключив все дерево в MethodCallExpression, который представляет вызов метода 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);
}

В этом случае у вас нет общего заполнителя T времени компиляции, поэтому вы будете использовать перегрузку Lambda, для которой не требуются сведения о типе времени компиляции, в результате чего вместо выражения<TDelegate> создается LambdaExpression.

Динамическая библиотека LINQ

Построение деревьев выражений с помощью фабричных методов достаточно сложное занятие; проще создавать строки. Динамическая библиотека LINQ предоставляет набор методов расширения для IQueryable, соответствующих стандартным методам LINQ в Queryable, и который принимает строки в специальном синтаксисе вместо деревьев выражений. Библиотека создает соответствующее дерево выражения из строки и может возвращать результирующий преобразованный IQueryable.

Например, предыдущий пример можно переписать следующим образом:

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

См. также