LINQ 式の動的フィルタ構築をシンプルにする Expression Tree ラッパー

複雑な検索条件を LINQ の Where に直書きすると、論理積・論理和・括弧が入り組んで可読性が著しく低下することがある。特に日付範囲の判定など、一行に収まらないケースでは意図が把握しにくくなる。

以下はその典型的な例。入院患者の投薬スケジュールを取得する際、開始日時と終了日時の間に含まれるレコードを抽出している。

public async Task<List<PatientMedicineScheduleInfo>> FetchSchedules(
    string patientId, string facilityId,
    DateTime beginDate, TimeSpan beginTime,
    DateTime closeDate, TimeSpan closeTime)
{
    return await _dbContext.PatientMedicineScheduleInfos
        .Where(x =>
            ((x.ScheduleDate == beginDate.Date && x.ScheduleTime >= beginTime)
                || x.ScheduleDate > beginDate.Date)
            && (x.ScheduleDate < closeDate.Date
                || (x.ScheduleDate == closeDate.Date && x.ScheduleTime <= closeTime))
            && x.InpatientID == patientId
            && x.HospitalID == facilityId
            && x.DeleteFlag != "*")
        .OrderBy(x => x.ScheduleDate).ThenBy(x => x.ScheduleTime)
        .ThenBy(x => x.GroupID).ThenBy(x => x.HISOrderSort)
        .ToListAsync();
}

この問題を解決するため、Expression Tree をラップした PredicateComposer を作成する。これにより個別の条件を変数として切り出し、直感的に組み合わせることが可能になる。

基本的な使い方

AndOr は拡張メソッドとして提供される。個別の式を定義してから合成するパターンを示す。

public async Task<List<PatientMedicineScheduleInfo>> FetchSchedules(
    string patientId, string facilityId,
    DateTime beginDate, TimeSpan beginTime,
    DateTime closeDate, TimeSpan closeTime)
{
    // 開始側の条件
    Expression<Func<PatientMedicineScheduleInfo, bool>> sameDayFrom =
        x => x.ScheduleDate == beginDate.Date && x.ScheduleTime >= beginTime;
    Expression<Func<PatientMedicineScheduleInfo, bool>> laterDay =
        x => x.ScheduleDate > beginDate.Date;

    // 終了側の条件
    Expression<Func<PatientMedicineScheduleInfo, bool>> sameDayTo =
        x => x.ScheduleDate == closeDate.Date && x.ScheduleTime <= closeTime;
    Expression<Func<PatientMedicineScheduleInfo, bool>> earlierDay =
        x => x.ScheduleDate < closeDate.Date;

    // 合成
    var fromCondition = sameDayFrom.Or(laterDay);
    var toCondition = sameDayTo.Or(earlierDay);
    var dateRange = fromCondition.And(toCondition);

    return await _dbContext.PatientMedicineScheduleInfos
        .Where(dateRange)
        .Where(x => x.InpatientID == patientId
            && x.HospitalID == facilityId
            && x.DeleteFlag != "*")
        .OrderBy(x => x.ScheduleDate).ThenBy(x => x.ScheduleTime)
        .ThenBy(x => x.GroupID).ThenBy(x => x.HISOrderSort)
        .ToListAsync();
}

条件付き合成

実行時に条件を満たした場合のみ式を追加したい場面では、IfAndIfOr を使用する。条件が不成立の場合は元の式がそのまま返される。

var predicate = PredicateComposer.AlwaysTrue<QuarterPlanWorkInfo>()
    .IfAnd(excludeIds.Any(),
        x => !excludeIds.Contains(x.APInterventionID))
    .IfAnd(specificId.HasValue,
        x => x.APInterventionID == specificId.Value);

実装

PredicateComposer.cs

public static class PredicateComposer
{
    public static Expression<Func<T, bool>> AlwaysTrue<T>() => _ => true;
    public static Expression<Func<T, bool>> AlwaysFalse<T>() => _ => false;

    public static Expression<Func<T, bool>> And<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        return left.Merge(right, Expression.AndAlso);
    }

    public static Expression<Func<T, bool>> Or<T>(
        this Expression<Func<T, bool>> left,
        Expression<Func<T, bool>> right)
    {
        return left.Merge(right, Expression.OrElse);
    }

    public static Expression<Func<T, bool>> IfAnd<T>(
        this Expression<Func<T, bool>> source,
        bool criterion,
        Expression<Func<T, bool>> append)
    {
        return criterion ? source.Merge(append, Expression.AndAlso) : source;
    }

    public static Expression<Func<T, bool>> IfOr<T>(
        this Expression<Func<T, bool>> source,
        bool criterion,
        Expression<Func<T, bool>> append)
    {
        return criterion ? source.Merge(append, Expression.OrElse) : source;
    }

    private static Expression<Func<T, bool>> Merge<T>(
        this Expression<Func<T, bool>> original,
        Expression<Func<T, bool>> additional,
        Func<Expression, Expression, Expression> combiner)
    {
        var parameterMap = original.Parameters
            .Select((param, idx) => new { param, idx })
            .ToDictionary(
                x => additional.Parameters[x.idx],
                x => x.param);

        var rewrittenBody = ParameterSubstitutor.Replace(additional.Body, parameterMap);
        var mergedBody = combiner(original.Body, rewrittenBody);

        return Expression.Lambda<Func<T, bool>>(mergedBody, original.Parameters);
    }
}

ParameterSubstitutor.cs

public sealed class ParameterSubstitutor : ExpressionVisitor
{
    private readonly IReadOnlyDictionary<ParameterExpression, ParameterExpression> _mappings;

    private ParameterSubstitutor(IReadOnlyDictionary<ParameterExpression, ParameterExpression> mappings)
    {
        _mappings = mappings;
    }

    public static Expression Replace(
        Expression expression,
        IReadOnlyDictionary<ParameterExpression, ParameterExpression> mappings)
    {
        var visitor = new ParameterSubstitutor(mappings);
        return visitor.Visit(expression);
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        return _mappings.TryGetValue(node, out var replacement)
            ? base.VisitParameter(replacement)
            : base.VisitParameter(node);
    }
}

検証コード

public sealed class PredicateComposerTests
{
    [Fact]
    public void And_EvaluatesLogicalConjunction()
    {
        Expression<Func<int, bool>> first = _ => true;
        Expression<Func<int, bool>> second = _ => false;

        var result = first.And(second).Compile();

        Assert.False(result(0));
    }

    [Fact]
    public void Or_EvaluatesLogicalDisjunction()
    {
        Expression<Func<int, bool>> first = _ => true;
        Expression<Func<int, bool>> second = _ => false;

        var result = first.Or(second).Compile();

        Assert.True(result(0));
    }

    [Fact]
    public void IfAnd_AppliesOnlyWhenCriterionHolds()
    {
        Expression<Func<int, bool>> baseExpr = _ => true;
        Expression<Func<int, bool>> extra = _ => false;

        var withCondition = baseExpr.IfAnd(true, extra);
        var withoutCondition = baseExpr.IfAnd(false, extra);

        Assert.False(withCondition.Compile()(0));
        Assert.True(withoutCondition.Compile()(0));
    }

    [Fact]
    public void IfOr_AppliesOnlyWhenCriterionHolds()
    {
        Expression<Func<int, bool>> baseExpr = _ => false;
        Expression<Func<int, bool>> extra = _ => true;

        var withCondition = baseExpr.IfOr(true, extra);
        var withoutCondition = baseExpr.IfOr(false, extra);

        Assert.True(withCondition.Compile()(0));
        Assert.False(withoutCondition.Compile()(0));
    }

    [Fact]
    public void Complex_NestingPreservesPrecedence()
    {
        Expression<Func<int, bool>> a = _ => false;
        Expression<Func<int, bool>> b = _ => true;
        Expression<Func<int, bool>> c = _ => true;

        var leftAssociative = a.And(b).Or(c);
        var rightAssociative = a.And(b.Or(c));

        Assert.True(leftAssociative.Compile()(0));
        Assert.False(rightAssociative.Compile()(0));
    }
}

タグ: C# Expression Trees LINQ Entity Framework Predicate Builder

9月15日 07:45 投稿