C# 9.0, .NET 5.0, .Entity Framework Core 5.0 を使って数ヶ月。当初抱いていた LINQ to Entities の機能向上への期待は打ち砕かれたが、それでも EF 6.0 や EF Core 2.0 に比べたら使いやすくなっているような気がする。本格的に使うにあたり、少し工夫をする。
左結合 Left Join の簡易記法 (syntax sugar)
LINQ to Entities での左結合は .GroupJoin() 1つでは済まず、 .GroupJoin() と .SelectMany() の合わせ技になるのだが、読みづらいし、書きづらい。
TableA .GroupJoin(TableB, l => new { JoinKey1 = l.JoinKey1, ... }, r => new { JoinKey1 = r.JoinKey1, ... }, (l, r) => new { Left = l, Right = r }) .SelectMany(x => x.Right.DefaultIfEmpty(), // .SelectMany() を駆り出した2段構成が美しくない (l, r) => new ResultRecord { Prop1 = l.Left.Prop1, // 結果作成時に左だけ .Left プロパティ・アクセスする非対称性が美しくない Prop2 = l.Left.Prop2, Prop3 = r == null ? null : r.Prop3, // r?.Prop3 は式木非対応 } );
内部結合 .Join() と違って左結合 .GroupJoin() は第5引数の resultSelector ラムダ式内で右テーブル・レコードを要素ではなくシーケンスで扱うことになるため、プロパティ・アクセスが左右非対称となって美しくなく、また、ハンドリングがけっこう面倒である。右側テーブル側に結合対象がなかったレコードは null になるわけで、.Any(), .FirstOrDefault(), null 伝搬演算子 (?.), null 合体演算子 (??), 三項演算子 (? :) を多用する *1 ことになる上に、LINQ to Object では機能するものの式木にできず、LINQ to Entities で機能させるためにさらに冗長に書き換えなければならない表現 *2*3 も頻繁にある。今後、null 許容参照型を有効にすること (#nullable enable) が推奨されるようになると、null に関してエラーや警告が多発し、この null ハンドリングと書き換え問題がさらに面倒になるのは間違いない。
そこで、この際、Syntax Sugar として左結合の簡略記法 .LeftJoin() を定義してみることにする。
#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using static System.Linq.Expressions.Expression; public static partial class ExtensionIQueryable { /// 2要素参照ラムダの辞書 private static Dictionary<Expression, Expression> DicTwoFactor { get; set; } = new(); /// /// Left Join 簡略記法用ヘルパーレコード /// private record LeftJoinHelperRecord<T, U> where T : notnull, new() where U : new() { public T Left { get; init; } = new T(); public U? Right { get; init; } = default; } /// /// Left Join 簡略記法 /// public static IQueryable<V> LeftJoin<T, U, V>(this IQueryable<T> leftSource, IQueryable<U> rightSource, Expression<Func<T, object>> leftKeySelector, Expression<Func<U, object>> rightKeySelector, Expression<Func<T, U?, V>> resultGenerator) where T : notnull, new() where U : new() where V : class { if (! DicTwoFactor.TryGetValue(resultGenerator, out var lambda)) { var typeH = typeof(LeftJoinHelperRecord<T, U>); var objectH = Parameter(typeH, "LeftJoinHelperRecord<T, U>"); DicTwoFactor .Add(resultGenerator, lambda = Lambda<Func<LeftJoinHelperRecord<T, U>, V>>( Invoke(resultGenerator, PropertyOrField(objectH, "Left"), PropertyOrField(objectH, "Right")), objectH)); } var lambdaGenerator = (Expression<Func<LeftJoinHelperRecord<T, U>, V>>)lambda; // 辞書に入っている値の型は決まっており常にキャスト可能 return leftSource .GroupJoin(rightSource, leftKeySelector, rightKeySelector, (l, r) => new { Left = l, Right = r }) .SelectMany(x => x.Right.DefaultIfEmpty(), (l, r) => new LeftJoinHelperRecord<T, U> { Left = l.Left, Right = r }) .Select(lambdaGenerator); // (l, r) => new { l.Prop ... r.Prop ... } } }
こんな使い方をする。
var context = new MyDbContext(); // DB アクセス用クラス (ユーザ定義クラス) var query = context.MarketData .Where( x => x.TickerCode == "NKY" && x.Indicator == "Price" ) .Take(5) .LeftJoin(context.TickerMaster, l => l.TickerCode, r => r.TickerCode, (l, r) => new { TickerCode = l.TickerCode, Indicator = l.Indicator, BaseDate = l.BaseDate, Value = l.Value, Note = r.Note, // 1要素の結合 (r != null が既知の場合 ... マスタを左結合してタグ付けをするときなど) Relation = r, // 結合対象の右レコード全体への参照 }); Console.WriteLine(query.ToQueryString()); // DB への発行 SQL 文を表示 query.Display(); // SQL 実行結果をクライアント側に持ってきて表示 (ユーザ定義メソッド)
出力結果は下記のとおり。(裏で動かしている DB は PostgreSQL。)
Note のように結合対象右レコード内の1要素を取り込むこともできるし、(Entity Data Model 上は) Relation のように結合対象の右レコード全体への参照として保持することもできる *4。発行 SQL 文をロガーを通さずに .ToQueryString() として参照できるようになったのは EF Core 5.0 での機能向上の1つ。
-- @__p_0='5' SELECT t.tickercode AS "TickerCode", t.indicator AS "Indicator", t.basedate AS "BaseDate", t.value AS "Value", b0.note AS "Note", b0.tickercode FROM ( SELECT b.tickercode, b.indicator, b.basedate, b.value FROM marketdata AS b WHERE (b.tickercode = 'NKY') AND (b.indicator = 'Price') LIMIT @__p_0 ) AS t LEFT JOIN tickermaster AS b0 ON t.tickercode = b0.tickercode
{ TickerCode = NKY, Indicator = Price, BaseDate = 04/03/2001 00:00:00, Value = 13124.47, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } } { TickerCode = NKY, Indicator = Price, BaseDate = 04/02/2001 00:00:00, Value = 12937.86, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } } { TickerCode = NKY, Indicator = Price, BaseDate = 03/30/2001 00:00:00, Value = 12999.70, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } } { TickerCode = NKY, Indicator = Price, BaseDate = 03/29/2001 00:00:00, Value = 13072.36, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } } { TickerCode = NKY, Indicator = Price, BaseDate = 03/28/2001 00:00:00, Value = 13765.51, Note = 日経 225, Relation = TickerMaster { TickerCode = NKY, Note = 日経 225 } }
*1:null 合体演算子 (??) は式木になるが null 伝搬演算子 (?.) はならない。そこで .Any() や三項演算子 (? :) の出番が増え、冗長になる。
*2:状態遷移を伴う switch 式を式木化できないのは仕方ないが、等価の式に変換できるはずの null 伝搬演算子 (?.) と with 式が C# コンパイラ内部で文 (手続) 扱いされ、式木化されないのは間抜け。
*3:with 式の式木化は C# 開発チームに提案したものの却下された。が、最近、LINQ to Entities で機能する .Select( x => x with { Prop1 = ... } ) の代替簡易記法を定義できたため、後日アップする。
*4:画面出力してフラットにすると、連想配列を内包するオブジェクトを JSON シリアライズしたかのような見映えになる。