実行時の状態に基づくクエリ

ほとんどの LINQ クエリでは、クエリの一般的な形状がコードで設定されます。 where 句を使用して項目をフィルター処理したり、orderby を使用して出力コレクションを並べ替えたり、項目をグループ化したり、評価を実行したりできます。 コードでは、フィルター、並べ替えキー、またはクエリの一部である他の式のパラメーターを指定できます。 ただし、クエリの全体的な形状は変更できません。 この記事では、System.Linq.IQueryable<T> インターフェイスとそれを実装する型を使用して、実行時にクエリの形状を変更する手法について説明します。

これらの手法を使用して実行時にクエリを作成します。この場合、一部のユーザーによる入力または実行時の状態によって、クエリの一部として使用するクエリ メソッドが変わります。 クエリ句を追加、削除、または変更して、クエリを編集したい場合があります。

Note

.cs ファイルの先頭に using System.Linq.Expressions;using static System.Linq.Expressions.Expression; を必ず追加してください。

データ ソースに対して IQueryable または IQueryable<T> を定義するコードについて考えてみましょう。

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

上記のコードを実行するたびに、同じ正確なクエリが実行されます。 クエリを拡張または変更する方法について学びましょう。 基本的に、IQueryable には次の 2 つのコンポーネントがあります。

  • Expression—式ツリーの形式である、現在のクエリのコンポーネントの言語に依存せずデータソースにも依存しない表現。
  • Provider— 現在のクエリを値または値のセットに具体化する方法を認識している LINQ プロバイダーのインスタンス。

動的なクエリのコンテキストでは、通常、プロバイダーは同じままとなります。クエリの式ツリーはクエリによって異なります。

式ツリーは変更できません。別の式ツリー — したがって、別のクエリ — が必要な場合は、既存の式ツリーを新しいものに変換する必要があります。 次のセクションでは、実行時の状態に応じて異なる方法でクエリを実行する特定の手法について説明します。

  • 式ツリー内から実行時の状態を使用する
  • その他の LINQ メソッドを呼び出す
  • LINQ メソッドに渡される式ツリーを変更する
  • Expression でファクトリ メソッドを使用して、Expression<TDelegate> 式ツリー式を作成する
  • IQueryable の式ツリーにメソッド呼び出しノードを追加する
  • 文字列を構築し、動的 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 メソッドを呼び出す

一般に、Queryable組み込みの LINQ メソッドでは、次の 2 つの手順を行います。

  • メソッド呼び出しを表す MethodCallExpression で現在の式ツリーをラップする。
  • ラップされた式ツリーをプロバイダーに戻し、プロバイダーの IQueryProvider.Execute メソッドを使用して値を返すか、IQueryProvider.CreateQuery メソッドを使用して変換されたクエリ オブジェクトを返す。

元のクエリを、System.Linq.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);

LinqKitPredicateBuilder などのほかのライブラリを使用して、さまざまな部分式を構成することもできます。

// 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 でファクトリ メソッドを使用して、最初から式ツリーを作成し、実行時に特定の要素型に合わせて式を調整することができます。

Expression<TDelegate> の構築

LINQ メソッドのいずれかに渡す式を構築する場合、実際には System.Linq.Expressions.Expression<TDelegate> のインスタンスを構築します。TDelegate は、Func<string, bool>Action、カスタム デリゲート型などの、何らかのデリゲート型です。

System.Linq.Expressions.Expression<TDelegate> は、次の例のような完全なラムダ式を表す LambdaExpression から継承されます。

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

LambdaExpression には次の 2 つのコンポーネントがあります。

  1. パラメーター リスト —(string x)Parameters プロパティによって表されます。
  2. 本文 —x.StartsWith("a")Body プロパティによって表されます。

Expression<TDelegate> を構築するための基本的な手順は次のとおりです。

  1. Parameter ファクトリ メソッドを使用して、ラムダ式内の各パラメーター (存在する場合) に ParameterExpression オブジェクトを定義する。
    ParameterExpression x = Parameter(typeof(string), "x");
    
  2. 定義した ParameterExpression と、Expression のファクトリ メソッドを使用して、LambdaExpression の本体を作成します。 たとえば、x.StartsWith("a") を表す式はこのように構築できます。
    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", [typeof(string)])!,
        Constant("a")
    );
    
  3. 適切な Lambda ファクトリ メソッドのオーバーロードを使用して、コンパイル時に型指定される Expression<TDelegate> にパラメーターと本文をラップします。
    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

次のセクションでは、LINQ メソッドに渡す Expression<TDelegate> を構築するシナリオについて説明します。 ファクトリ メソッドを使用してこれを行う方法の完全な例を示します。

実行時に完全なクエリを作成する

複数のエンティティ型で動作するクエリを記述したい場合があります。

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

これらのいずれかのエンティティ型については、string フィールドの 1 つに特定のテキストが含まれているエンティティのみをフィルター処理して返す必要があります。 Person については、FirstNameLastName のプロパティを検索する必要があります。

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> 用にカスタム関数を 1 つと、IQueryable<Car> 用にもう 1 つを記述することもできますが、次の関数では、特定の要素型に関係なく、このフィルターを既存のすべてのクエリに追加します。

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

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<TDelegate> の式ツリーに追加する

IQueryable の代わりに IQueryable<T> を使用する場合は、汎用 LINQ メソッドを直接呼び出すことはできません。 代替手段の 1 つは、上記の例に示されているように内部式ツリーをビルドし、リフレクションを使用して、式ツリーに渡すときに適切な 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",
        [elementType],
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

この場合、コンパイル時の T 汎用プレースホルダーがないため、コンパイル時に型情報が必要なく、Lambda の代わりに LambdaExpression が生成される、Expression<TDelegate> のオーバーロードを使用します。

動的 LINQ ライブラリ

ファクトリ メソッドを使用した式ツリーの構築は比較的複雑です。文字列を作成する方が簡単です。 動的 LINQ ライブラリでは、Queryableで標準 LINQ メソッドに対応する IQueryable の拡張メソッドのセットを公開し、式ツリーではなく特殊な構文で文字列を受け入れます。 ライブラリで文字列から適切な式ツリーが生成され、結果として変換された 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);
}