Functional Programming w/ C# LINQ - with 初期化子の IQueryable<T> 対応版代替簡易記法

C# 9.0 / .NET 5.0 に長らく期待していたことの1つに record の with 初期化子がある。これは record の一部のプロパティを異なる値に置き換えるためのもので y = x with { Value = x.Value * 10 } と書くと x の Value プロパティだけを 10 倍にしたオブジェクトを新規生成して y に代入してくれる。

ところが .NET 5.0 で使ってみると、この with 初期化子が式木にならず、したがって LINQ to Entities で機能しない。with 初期化子は式のように見えてコンパイラ内部では .Clone() してから指定されたプロパティを上書きしているため、文 (手続) 扱いになっている。

この with 初期化子を式にしろと C# 開発チームに改良を提案するも却下される。一方で、C# 開発チームのメンバー曰く、式木になる x = new MyRecord { Value = 1 } というオブジェクト初期化子も 0 埋めデータで class / record の側を作ってからプロパティを1つ1つ上書き代入していると。おいおい同じ手順を踏んでいるのに片方が式になりもう片方が式にならないのは非対称でおかしいじゃないか、と食い下がったものの、醜い仕様であることを認めた (、でもたぶん彼らはもっと優先度の高い課題をたくさん抱えている) ため、議論はそこでおしまいにした。

しかし、with 初期化子は、本来、データ加工を頻繁に繰り返す LINQ to Entities でこそ活きるはずの生産性向上ツールだ。これがあると置換対象ではないプロパティに値代入する記述を省ける。オンメモリでオブジェクトを扱う LINQ to Object よりも DB を扱う LINQ to Entities の方がプロパティ (カラム) 数が断然多い。LINQ to Object ならば .Clone() をちょっと加工すれば with と同等のことが簡単に実現できる。with 初期化子が LINQ to Object でのみ有効というのは趣旨からいって本末転倒だ。

そこで LINQ to Entities で利用可能な代替手段を作ることに挑戦してみた *1

目標

LINQ to Entities (IQueryable<T>) で

  .Select( x => x with { Value = x.Value * 10 } ) 

と書いても、コンパイルエラー "error CS8849: An expression tree may not contain a with-expression." が出て機能しないため

  .SelectWith( x => new { Value = x.Value * 10 } ) 

と書くと同等の効果を得られるようにする。

with 初期化子の IQueryable<T> 対応版代替簡易記法

以前C# 8.0 以前用に with 初期化子相当となる .Clone() メソッドを定義した。これは LINQ to Object (IEnumerable<T>) にしか対応しておらず LINQ to Entities では機能しないが、まったく同じコンセプトを IQueryable<T> へ応用して、シーケンスの各オブジェクトの一部プロパティを置換する .SelectWith() という LINQ メソッドを定義する。

LINQ to Object を対象とした前回は実行効率の観点から式木にしたが、LINQ to Entities を対象とする今回はクエリプロバイダが解釈・実行可能な形での式木化が必須である。ポイントは以下のとおり。

  • 前回の .Clone() メソッド定義から引き継ぐコンセプト
    • ユーザは、オリジナルのオブジェクトのうち置換したいプロパティのみを含有するよう定義された匿名型オブジェクトを引数に指定する
    • オリジナルをクローンして新しいオブジェクトを生成したうえで、引数に与えられた匿名型のプロパティの名称・型がオリジナルのそれと一致していれば、匿名型オブジェクト側のインスタンス値を採用してプロパティへ代入して返す
    • オリジナルと名称・型が一致していないプロパティを匿名型に指定した場合は、ランタイムエラーとする *2
    • ただし、オリジナル側が匿名オブジェクト側を Nullable 形式にした型になっている、というプロパティはその差異を許容して Nullable 型にキャストして代入する *3
    • 与えられる引数は辞書にキャッシュしておき、同じものが指定された場合は辞書を検索する
  • 今回の進化
    • Null 参照許容型を有効化した記述にしている *4
    • 引数で与えるのは、匿名型オブジェクトそのものではなく、オリジナルのオブジェクトから置換候補となる匿名型オブジェクトを導出するラムダ式

この「式木を用いて、オリジナルのオブジェクトとそれを入力としてラムダ式で導出する置換候補匿名型オブジェクトの双方を参照しつつ、新しいオブジェクトをワンショットで初期化生成する」というところが最大のポイントであり、難所となる *5

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using static System.Linq.Expressions.Expression;

public static partial class ExtensionIQueryable {
  /// メンバーバインド・ラムダの辞書
  private static Dictionary<Expression, Expression> DicMemberBind { get; set; } = new();

  ///
  /// 一部のプロパティを置き換える Select
  /// C# 9 with 式が 式木 / IQueryable<T> 非対応のため、代替としての IQueryable<T> 版 Select( x => x with { Prop = ... } )
  ///
  public static IQueryable<T> SelectWith<T, U>(this IQueryable<T> source, Expression<Func<T, U>> lambdaReplace) where T : notnull, new() where U : notnull {
    var original = Parameter(typeof(T), "original");
    var subst    = Invoke(lambdaReplace, original);
    var propT    = typeof(T).GetProperties();
    var propU    = typeof(U).GetProperties();
    var mismatch =
      propU
        .GroupJoin(propT,
                   l      => l.Name,
                   r      => r.Name,
                   (l, r) => new { SubstPropName = l.Name, IsMismatched = ! r.Any() })
          .Where( x => x.IsMismatched )
            .Select( x => x.SubstPropName )
              .ToList();

    if (mismatch.Any()) // 存在しないプロパティの置換を指定された場合
      throw new ArgumentException($"ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです。" +
                                    $"-- {string.Join(", ", mismatch)}");

    if (! DicMemberBind.TryGetValue(lambdaReplace, out var lambda)) {
      var bind =
        propT
          .GroupJoin(propU,
                     l      => l.Name,
                     r      => r.Name,
                     (l, r) => new {
                       Prop  = typeof(T).GetMember(l.Name)[0],
                       Value = (l, r.FirstOrDefault()) switch {
                         (    _, null )                                       => (Expression)        PropertyOrField(original, l.Name),                  // t.Prop =                            t .Prop
                         (var o, var s) when o.PropertyType == s.PropertyType => (Expression)        PropertyOrField(subst   , l.Name),                  // t.Prop =              lambdaReplace(t).Prop
                         (var o, var s) when o.IsNullableOf(s)                => (Expression)Convert(PropertyOrField(subst   , l.Name), l.PropertyType), // t.Prop = (Nullable<>) lambdaReplace(t).Prop
                         (var o, var s)                                       =>
                           throw new ArgumentException($"{o.PropertyType.FullName} 型のプロパティを {s.PropertyType.FullName} 型で置き換えようとしました。"),
                       },
                     })
            .Select( mb => Bind(mb.Prop, mb.Value) ); // IEnumerable<MemberBinding>

      DicMemberBind.Add(lambdaReplace, lambda = Lambda<Func<T, T>>(MemberInit(New(typeof(T)), bind), original));
    }

    return source.Select( (Expression<Func<T, T>>)lambda ); // 辞書に入っている値の型は決まっており常にキャスト可能
  }

  /// あるプロパティの型が別のプロパティの Nullable<> 型であるか判定する
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  private static bool IsNullableOf(this PropertyInfo o, PropertyInfo s) =>
    (o.PropertyType, s.PropertyType) switch {
      (var ot,      _) when ot.IsGenericType                != true               => false,
      (var ot,      _) when ot.GetGenericTypeDefinition()   != typeof(Nullable<>) => false,
      (var ot, var st) when ot.GenericTypeArguments.First() != st                 => false,
      (     _,      _)                                                            => true , // T = U? の場合
    };
}

IQueryable<T> のラムダ式評価時 (ランタイムでの SQL コンパイル時) に、

  1. 引数に与えた「オリジナルから置換候補となる匿名型オブジェクトを導出するラムダ式」を Expression.Invoke() で評価して置換候補オブジェクトを求め、
  2. 入力と同じ型のオブジェクトを Expression.New() で新規作成し、
  3. オリジナルのプロパティと置換候補のプロパティとを LINQ to Object の .GroupJoin() メソッドを用いて比較して名前と型をチェックし、
  4. 必要としている側のインスタンス値を採用するようにプロパティ・アクセスを Expression.PropertyOrField() で選択する MemberBinding を構築し、
  5. この MemberBinding を使って新規作成オブジェクトのメンバーを Expression.MemberInit() で初期化して返す、というラムダ式を導出し、
  6. このラムダ式によって求めるべきオブジェクトを生成して出力する

という手順を踏んでいる。


このように使う。

var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス)
var query   =
  context.MarketData
    .Where( x => x.TickerCode == "NKY" && x.Indicator == "Volume" )
      .Take(5)
        .SelectWith( x => new { Value = Math.Floor(x.Value * 10.0 ?? 0.0) } );

Console.WriteLine(query.ToQueryString());  // DB への発行 SQL 文を表示
query.Display();                           // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)


出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)

-- @__p_0='5'
SELECT b.tickercode AS "TickerCode", b.indicator AS "Indicator", b.basedate AS "BaseDate", floor(COALESCE(b.value * 10.0, 0.0)) AS "Value"
FROM marcketdata AS b
WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price')
LIMIT @__p_0
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/01/2011 00:00:00, Value = 1115 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/04/2011 00:00:00, Value = 1313 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/05/2011 00:00:00, Value = 1325 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/06/2011 00:00:00, Value = 1377 }
MarketData { TickerCode = NKY, Indicator = Volume, BaseDate = 07/07/2011 00:00:00, Value = 1331 }

オリジナルの Value は double? 型 (not null 制約なし) で 111.59, 131.38, 132.58 ... と小数点以下第2位までの値が格納されている。これが .SelectWith() メソッドで 10 倍されて小数点以下切り捨てとなって出力される。ポイントは C# のメソッド Math.Floor() や演算子 ?? がきちんと DB 関数の floor() や coalesce() に翻訳されていることと、double 入力・double 出力である Math.Floor() が、引数においては double? 型の Value を ?? を用いて明示的に double 型に強制する必要がある一方、Value への代入においては返値を明示的に double? へ型変換する必要はないということ。

また、プロパティの名や型が合致しないと以下のようなランタイムエラーとなる。

// ランタイムエラー「double? 型のプロパティを string 型で置換しようとしました」となる
.SelectWith( x => new { Value = "" + Math.Floor(x.Value * 10.0 ?? 0.0) } ) 

// ランタイムエラー「ラムダ式の返り値に置換候補として指定されたオブジェクトの次のプロパティは置換対象に存在しないプロパティです -- Value2」となる
.SelectWith( x => new { Value2 = Math.Floor(x.Value * 10.0 ?? 0.0) } )

代替簡易記法の限界

定義した .SelectWith() は

var multiplier = 10;

DataRecords
  .Select( x => x with { Value = x.Value * multiplier } );

のように、生成したいオブジェクト自体のプロパティやシーケンス内で不変の変数や定数を参照して取り込む分には問題がないが、

// 例 1. ブジェクトを新規生成してそのシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Select( c => new DataRecord() with { Date = c.Date } );

// 例 2. オブジェクトをプロパティ修正したシーケンスを返すにあたり、別タイプのシーケンス Calendar のプロパティを取り込みたい
Calendar
  .Join(DataRecords,
        l      => l.Date,
        r      => r.Date,
        (l, r) => r with { IsBusinessDay = l.IsBusinessDay });

という (シーケンス内で変化する) 外部変数を巻き込むクロージャー的な使い方には対応できない。

with に絡む部分のラムダ式を返す式木を作成すればクロージャー的な用途にも対応できる *6 が、巻き込む外部変数の数や位置に応じてラムダ式を作成しなければならないため、汎用性がない。やはりコンパイラが with を手続ではなく式として解釈して MemberBinding & MemberInit して IQueryable 対応してくれるのが望ましい。

*1:LINQ to Object では動くが LINQ to Entities では動かないというありがちな事象で苦しむこと 1 ヶ月。さきの .LeftJoin() 簡易記法を定義する際に解決法を発見した。LINQ to Object で動き LINQ to Entities で動かなかったのは、Expression.Block() を用いて式木構築してしまったため。

*2:置換候補となる匿名型のプロパティをミススペルしている可能性を警告するため。

*3:double? Value というプロパティに対する代入式では、new { Value = 1.0 } が NG となり、new { Value = (double?) 1.0 } と冗長に記述しなければならなくなるため。

*4:より厳しい型チェックでも通るように記述した。

*5:何もないところから構築するのはかなり試行錯誤を要する難しいパズルだったが、できたものを眺めると大したことないように見える。コロンブスの卵。

*6:例1 は実現可能であることを確認済み。