C# で実行時クエリ条件を動的に構築する Lambda 式の設計

CRUD アプリケーションにおいて、検索機能は柔軟な条件対応が求められる典型的なユースケースです。固定された WHERE 句ではなく、UI 側から渡される動的なフィルタをもとに、実行時に Expression<Func<T, bool>> を生成することで、データアクセス層の再編成を最小限に抑えられます。

本手法では、以下の責務分離を採用します:

  1. フロントエンドが入力値をキー/値ペア(例:{"Name": "山田", "Age": "35"})で送信
  2. バックエンドは事前に定義された「条件マッピング設定」(DB や JSON 構成ファイル)を参照
  3. 各キーに対応するフィールド名・比較演算子・データ型を取得し、値をバインド
  4. それらを元に Expression Tree を組み立て、LINQ to Entities 向けの動的述語を生成
  5. DbContext に適用してクエリ実行

基本エンティティと設定モデル

検索条件のメタ情報は永続化可能とするため、ID を持つエンティティとして定義します:

public interface IQueryRule
{
    int Id { get; set; }
}

public class QueryRule : IQueryRule
{
    public int Id { get; set; }
    public string Scope { get; set; }        // 例: "ProductSearch"
    public string PropertyPath { get; set; } // プロパティ名またはネストパス("Category.Name")
    public ComparisonOperator Operator { get; set; }
    public DataTypeKind ValueType { get; set; }
    public string RawValue { get; set; }
}

列挙型と型安全な比較定義

public enum ComparisonOperator
{
    Equals = 1,
    GreaterThan = 2,
    GreaterThanOrEqual = 3,
    LessThan = 4,
    LessThanOrEqual = 5,
    Contains = 6,
    StartsWith = 7,
    EndsWith = 8
}

public enum DataTypeKind
{
    Integer = 1,
    String = 2,
    Decimal = 3,
    DateTime = 4,
    Boolean = 5
}

動的述語ビルダークラス

汎用性を高めるため、ジェネリックな述語構築ロジックをカプセル化します:

public static class DynamicPredicateBuilder
{
    public static Expression<Func<T, bool>> Build<T>(IEnumerable<QueryRule> rules)
    {
        var parameter = Expression.Parameter(typeof(T), "x");
        Expression body = Expression.Constant(true);

        foreach (var rule in rules.Where(r => !string.IsNullOrWhiteSpace(r.RawValue)))
        {
            try
            {
                var propertyAccess = BuildPropertyAccessExpression(parameter, typeof(T), rule.PropertyPath);
                var constantValue = ConvertToTypedConstant(rule.RawValue, rule.ValueType);
                var comparison = BuildComparisonExpression(propertyAccess, constantValue, rule.Operator, rule.ValueType);

                body = Expression.AndAlso(body, comparison);
            }
            catch
            {
                // 無効なルールはスキップ(ログ出力は省略)
                continue;
            }
        }

        return Expression.Lambda<Func<T, bool>>(body, parameter);
    }

    private static Expression BuildPropertyAccessExpression(Expression target, Type targetType, string path)
    {
        return path.Split('.').Aggregate(target, (expr, part) =>
            Expression.PropertyOrField(expr, part));
    }

    private static ConstantExpression ConvertToTypedConstant(string raw, DataTypeKind kind)
    {
        return kind switch
        {
            DataTypeKind.Integer => Expression.Constant(int.Parse(raw)),
            DataTypeKind.String => Expression.Constant(raw),
            DataTypeKind.Decimal => Expression.Constant(decimal.Parse(raw)),
            DataTypeKind.DateTime => Expression.Constant(DateTime.Parse(raw)),
            DataTypeKind.Boolean => Expression.Constant(bool.Parse(raw)),
            _ => Expression.Constant(raw)
        };
    }

    private static BinaryExpression BuildComparisonExpression(
        Expression left, ConstantExpression right, ComparisonOperator op, DataTypeKind type)
    {
        return op switch
        {
            ComparisonOperator.Equals => Expression.Equal(left, right),
            ComparisonOperator.GreaterThan => Expression.GreaterThan(left, right),
            ComparisonOperator.GreaterThanOrEqual => Expression.GreaterThanOrEqual(left, right),
            ComparisonOperator.LessThan => Expression.LessThan(left, right),
            ComparisonOperator.LessThanOrEqual => Expression.LessThanOrEqual(left, right),
            ComparisonOperator.Contains when type == DataTypeKind.String =>
                Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) }), right),
            ComparisonOperator.StartsWith when type == DataTypeKind.String =>
                Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), right),
            ComparisonOperator.EndsWith when type == DataTypeKind.String =>
                Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) }), right),
            _ => Expression.Equal(left, right)
        };
    }
}

サービス層での利用例

public class ProductSearchService
{
    private readonly AppDbContext _context;

    public ProductSearchService(AppDbContext context) => _context = context;

    public IQueryable<ProductDto> SearchProducts(Dictionary<string, string> filters, string scope = "ProductSearch")
    {
        // DB から該当スコープのルールを取得し、UI 値で上書き
        var rules = _context.QueryRules
            .Where(r => r.Scope == scope)
            .AsEnumerable() // ToList() ではなく AsEnumerable() で後続処理をメモリ上で実行
            .Select(r =>
            {
                if (filters.TryGetValue(r.PropertyPath, out var val))
                    r.RawValue = val;
                return r;
            })
            .Where(r => !string.IsNullOrEmpty(r.RawValue))
            .ToList();

        var predicate = DynamicPredicateBuilder.Build<Product>(rules);
        return _context.Products
            .Where(predicate)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price,
                CategoryName = p.Category.Name
            });
    }
}

タグ: C# ExpressionTree LINQtoEntities dynamic-query EntityFrameworkCore

5月23日 04:08 投稿