Functional Programming w/ C# LINQ - 左結合 Left Join の簡易記法

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 シリアライズしたかのような見映えになる。