CRUD アプリケーションにおいて、検索機能は柔軟な条件対応が求められる典型的なユースケースです。固定された WHERE 句ではなく、UI 側から渡される動的なフィルタをもとに、実行時に Expression<Func<T, bool>> を生成することで、データアクセス層の再編成を最小限に抑えられます。
本手法では、以下の責務分離を採用します:
- フロントエンドが入力値をキー/値ペア(例:
{"Name": "山田", "Age": "35"})で送信 - バックエンドは事前に定義された「条件マッピング設定」(DB や JSON 構成ファイル)を参照
- 各キーに対応するフィールド名・比較演算子・データ型を取得し、値をバインド
- それらを元に Expression Tree を組み立て、LINQ to Entities 向けの動的述語を生成
- 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
});
}
}