複雑な検索条件を 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 を作成する。これにより個別の条件を変数として切り出し、直感的に組み合わせることが可能になる。
基本的な使い方
And・Or は拡張メソッドとして提供される。個別の式を定義してから合成するパターンを示す。
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();
}
条件付き合成
実行時に条件を満たした場合のみ式を追加したい場面では、IfAnd・IfOr を使用する。条件が不成立の場合は元の式がそのまま返される。
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));
}
}