Programming w/ C# ~ 抽象 record データコンテナ・テンプレートから派生クラスを Reflection.Emit で動的生成

抽象 record データコンテナ・テンプレートから派生クラスを Reflection.Emit で動的生成

やりたいこと

下記のようなコードがある。

public abstract record Format {
  // EntityFrameworkCore で利用する共通機能を書く
  // プロパティは書かない
}

// データコンテナ・テンプレート (例えばこのようなもの. 実際は業務データのフォーマット.)
[PrimaryKey(nameof(id))]
public abstract record BaseFormat : Format {
  public int    id   { get; init; }
  public string text { get; init; }
}

ここから、下記のようなコードを Reflection.Emit で動的に生成したい。

[Table("table_derived202308", Schema = "oltp")]
public record DerivedFormat202308 : BaseFormat {
}

EntityFrameworkCore を ORM として用いてデータベース接続するのだが、Code First でも Model First でも EntityFrameworkCore を OLAP 目的で使うには2点難点がある。

  • 複雑な集合演算が苦手
  • テーブル (に紐づけるデータコンテナの型) を動的に作成できない

まあ、1点目は仕方ない。EntityFrameworkCore はメモリと DB 間の激しい相互作用に耐える ORM として設計されていて、テーブル構成をオブジェクト構成に見立てる方向 (Repository Pattern ORM) を目標としている *1 から。すべてを DB サーバーサイドで演算する業務・分析系はクエリビルダを使えということなのだろう。それはそれで不満 *2*3 があって、解消するために自分なりのソリューションを構築したが、それはまた別途紹介する。

ここでの問題は2点目。コード (データコンテナの型) を静的に書かなければならない? いやいや、OLAP のビッグデータとか 1 テーブルでは済まないから、分割統治するためにパラメータ与えて複数のテーブルをシステマティックに作成したいですけど? 年間 n 億レコードの会計データなんか溜め込むとしたら、期間分割して年月でテーブル分けしたいと思うのは当然。この CREATE TABLE を人手なんかでは書けませんって。テーブル数も多いし、1つ1つのテーブルだって単純構造ではなく、多段階テーブル・パーティショニングした上で末端テーブルにインデックス付けたりするから SQL は長くなる。これは自動化案件。

要するに EntityFrameworkCore で Model First 的にデータコンテナの型を起点に (でも実際は .ExecuteSqlRaw("CREATE TABLE ...") で) OLAP データキューブ・テーブルを動的に作りたいぞーということ。

というわけで、Reflection.Emit で動的に派生クラス作ってみるぞー。

難所

新しい型を作るには Reflection.Emit を使う。Reflection.Emit は .NET VM の中間コードを組み立てるもの。これがまあ参考情報がなくて難しい。SharpLab などを使って IL ダンプしてみるけど詳しくはわからない (から Chat GPT に解釈させる)。
record class は難しい。なぜなら、プログラマがコードを書かなくても基本機能を C# コンパイラがコード自動生成するものだから。要はコンパイラがやるコード補完を (部分的に) 再現するということ。
継承が難しい。派生クラスから自クラスや基底クラスのコンストラクタやクローンメソッドをどうコールしているか (よく見えないオートマトンを) よく洞察・想像しないと再現できない。
Chat GPT の扱いが難しい。Chat GPT はコード書きは速いが、世間であまり手掛けられていないこなれていない領域に対する知恵はほとんど持ち合わせていない。

試行錯誤すること丸 3 日。Chat GPT にコード書きさせること 20 数回。こちらが期待していることを Chat GPT が汲んでくれなくて堂々巡りすること 5 巡。こちらが知恵を絞り、手を変え品を変え、いろんな角度からちょっとずつヒントを与えて、ほんの少しだけ正解に近づいたかなと思える部分・部分をこそぎ取ってこちらで組み合わせて捏ねていく作品作り ...

結果

結果できたのがコレ。

using System;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;

public static class DynamicFormatCreator {
  // static property ***************************************************************************************************

  private static string AssemblyName { get; } = "Assembly";
  private static string ModuleName   { get; } = nameof(DynamicFormatCreator);
  private static string NameSpace    { get => typeof(DynamicFormatCreator).Namespace is {} ns ? $"{ns}." : string.Empty; }

  // static method *****************************************************************************************************

  // 基底 (抽象) クラスである Format のテンプレートから具体クラスを動的に生成する
  // 下記が生成される
  //
  //   [Table($tableName$, Schema = $schemaName$])]
  //   public class $formatTypeName$ : TBaseFormat {}
  public static Type CreateFormat<TBaseFormat>(string derivedTypeName, string tableName, string schemaName) where TBaseFormat : Format {
    var assemblyBuilder    = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(AssemblyName), AssemblyBuilderAccess.Run);
    var moduleBuilder      = assemblyBuilder.DefineDynamicModule(ModuleName);
    var baseType           = typeof(TBaseFormat);
    var derivedTypeBuilder = moduleBuilder
      .DefineType(derivedTypeName, TypeAttributes.Public, baseType)
      .SetTableAttribute(tableName, schemaName);

    // 派生クラスのコンストラクタを作成する
    baseType.DefineCtor(derivedTypeBuilder, isSelfRef: true ).As(out var derivedCtorBuilder); // 自己参照型コンストラクタ new TDerived(TBase that)
    baseType.DefineCtor(derivedTypeBuilder, isSelfRef: false);                                // デフォルトコンストラクタ new TDerived()

    // 派生クラスの <Clone>$ メソッドを作成する
    baseType.DefineCloneMethod(derivedTypeBuilder, derivedCtorBuilder);

    return derivedTypeBuilder.CreateType();
  }

  // 派生クラスのコンストラクタビルダーを作成し、基底クラスのコンストラクタを呼び出す IL コードを生成する
  private static ConstructorBuilder DefineCtor(this Type baseType, TypeBuilder derivedTypeBuilder, bool isSelfRef) {
    var baseCtorArg    = isSelfRef ? new Type[] { baseType           } : Type.EmptyTypes;
    var derivedCtorArg = isSelfRef ? new Type[] { derivedTypeBuilder } : null           ;
    var ctorBuilder    = derivedTypeBuilder.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, derivedCtorArg);
    var il             = ctorBuilder.GetILGenerator();
    var baseCtor       = baseType
      .GetConstructor(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance, null, baseCtorArg, null)
      ?? throw new NotImplementedException($"Constructor .ctor({(isSelfRef ? baseType.Name : string.Empty)}) on {baseType.Name} was not found.");

    il.Emit(OpCodes.Ldarg_0);

    if (isSelfRef)
      il.Emit(OpCodes.Ldarg_1);

    il.Emit(OpCodes.Call, baseCtor);
    il.Emit(OpCodes.Ret);

    return ctorBuilder;
  }

  // 派生クラスのメソッドビルダーを作成し、派生クラスのコンストラクタを呼び出す IL コードを生成する
  private static MethodBuilder DefineCloneMethod(this Type baseType, TypeBuilder derivedTypeBuilder, ConstructorBuilder derivedCtorBuilder) {
    var cloneMethodName    = "<Clone>$";
    var cloneMethodBuilder = derivedTypeBuilder
      .DefineMethod(cloneMethodName, MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, derivedTypeBuilder, null)
      .SetCtorAttribute<PreserveBaseOverridesAttribute>()         // <Clone>$ メソッドにコンストラクタ系属性を追加する
      .SetCtorAttribute<CompilerGeneratedAttribute>();
    var il                 = cloneMethodBuilder.GetILGenerator(); // IL ジェネレーターを取得し、<Clone>$ メソッドの本体を生成する
    var baseCloneMethod    = baseType                             // <Clone>$ メソッドを基底クラスのメソッドとしてオーバーライドする
      .GetMethod(cloneMethodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
      ?? throw new NotImplementedException($"Method {cloneMethodName}() on {baseType.Name} was not found.");

    derivedTypeBuilder                                            // Override
      .DefineMethodOverride(cloneMethodBuilder, baseCloneMethod);

    il.Emit(OpCodes.Ldarg_0);                                     // IL コードとして、自分自身をロード
    il.Emit(OpCodes.Newobj, derivedCtorBuilder);                  // 新しいインスタンスを作成するためにコンストラクタを呼び出す
    il.Emit(OpCodes.Ret);                                         // 戻り値として返す

    return cloneMethodBuilder;
  }

  // TypeBuilder にテーブル属性を付与する
  private static TypeBuilder SetTableAttribute(this TypeBuilder typeBuilder, string tableName, string schemaName) {
    var attribType     = typeof(TableAttribute);
    var attribCtor     = attribType.GetConstructor(new[] { typeof(string) })   ?? throw GetException_("Constructor .ctor(string)");
    var attribProperty = attribType.GetProperty(nameof(TableAttribute.Schema)) ?? throw GetException_("Property .Schema");
    var attribBuilder  = new CustomAttributeBuilder(attribCtor, new object[] { tableName }, new[] { attribProperty }, new object[] { schemaName });

    typeBuilder.SetCustomAttribute(attribBuilder);

    return typeBuilder;

    // local method ********************
    static Exception GetException_(string message) =>
      new NotImplementedException($"{message} on the attribute '{nameof(TableAttribute)}' was not found.");
  }

  // MethodBuilder にコンストラクタ系属性を付与する
  private static MethodBuilder SetCtorAttribute<TAttribute>(this MethodBuilder methodBuilder) where TAttribute : Attribute {
    var attrib = typeof(TAttribute);
    var ctor   = attrib.GetConstructor(Type.EmptyTypes)
      ?? throw new NotImplementedException($"Constructor .ctor() on {attrib.Name} was not found.");

    methodBuilder.SetCustomAttribute(new CustomAttributeBuilder(ctor, new object[] {}));

    return methodBuilder;
  }
}
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;

// ユーティリティ
public static ExtensionGeneric {
  public static T1 As<T1>(this T1 target, out T1 val1) =>
    val1 = target;

  public static string Join(this IEnumerable<object> source, string delimiter) =>
    string.Join(delimiter, source.Select( x => x.ToString() ));

  public static TFormat New<TFormat>(this Type type) where TFormat : Format =>
    type.IsAssignableTo(typeof(TFormat)) switch {
      true => (TFormat)(type.GetDefaultInstance() ?? throw new NotImplementedException($"Default constructor on {type.Name} wan not found.")),
      _    => throw new ArgumentException($"{type.Name} is not assignable to {typeof(TFormat).Name}."),
    };

  public static string RegexReplace(this string target, string pattern, Func<string[], string> replacer, RegexOptions option = RegexOptions.None) =>
    Regex.Replace(target, pattern, new MatchEvaluator( m => replacer(m.Groups.Cast<Group>().Select( x => x.ToString() ).ToArray()) ), option);

  public static object? GetDefaultInstance(this Type type) =>
    type.GetConstructor(Type.EmptyTypes)?.Invoke(null);

  public static string ToStructureString(this Type type) =>
    (type.GetCustomAttribute<TableAttribute>() switch {
      {} attrib => new[] { $"[Table(\"{attrib.Name}\", Schema = \"{attrib.Schema}\")]" },
      _         => Enumerable.Empty<string>(),
    })
      .Append($"{type} : {$"{type.BaseType}".RegexReplace(@"^.*?([^\.]+)$", m => m[1])} {{")
      .Concat(type
        .GetProperties()
        .Select( x => $"  public {x[0]} {x[1]} {{ get; init; }}" ))
      .Append("}")
      .Join(Environment.NewLine);
}
var type = DynamicFormatCreator.CreateFormat<BaseFormat>("DerivedFormat202308", "table_derived202308", "olap");
var inst = type.New<BaseFormat>() with {
  id   = 1,
  text = "test",
};

Console.Error.WriteLine(type.ToStructureString());

// displayed as follows:
//
// [Table("table_derived202308", Schema = "oltp")]
// public record DerivedFormat202308 : BaseFormat {
//  public int    id   { get; init; }
//  public string text { get; init; }
// }

Console.Error.WriteLine(inst);

// displayed as follows:
// 
// DerivedFormat202308 { id = 1, text = test }

ポイント

ポイントはこういうことだった。

  • デフォルトコンストラクタ new TDerivedFormat() を IL で定義するだけではダメ。<Clone>$() という隠れクローンメソッドを定義しなければ。
  • <Clone>$() という隠れクローンメソッドを定義するだけではダメ。それをオーバーライドメソッドとして定義しなければ。
  • <Clone>$() という隠れクローンメソッドをオーバーライド定義するだけではダメ。自己参照型コンストラクタ new TDerivedFormat(TBaseFormat that) を定義しなければ。

まあ、本来の record クラスは Equals() や GetHashCode() なども定義しなければならないため、また、本来の派生クラスは基底の定義に加えてプロパティやメソッドがありこれも定義すべきであるため、本当の継承では書くべきコード量はもっと増えるのだが。
いまのままでは、定義が不足しているメソッドはおそらく基底クラスのそれが用いられる。ここでは、具象クラスであることと Table 属性が付与されていることだけが派生クラスと基底クラスの相違点で、プロパティもメソッドも全く同じである、という形でしか使わないため、ここまでにする。

*1:このトピックはここで考察している。→ PythonのDjangoのデータベース操作。ORMと生Queryどっちを使う方がいいのでしょうか?(ORMで調べても出来ない作業を生Queryでやってもええんやろか)に対するNiwa Yosukeさんの回答 - Quora

*2:クエリビルダの長所は SQL を自由に組めること。対する短所はデータコンテナの型安全性が担保されないテキスト編集であること。ここを改善したのが DbExtensions ライブラリだが、これでもまだ少し不満がある。

*3:DbExtensions を用いてもまだ残る不満は、EntityFrameworkCore の IQueryable<T> のようにデータコンテナ T に対する型安全性と再利用性・応用性が高くないこと。要はプリミティブ型ではなく "データコンテナ T" に着目した関数型プログラミングで Fluent API 的に流れるように書きつつ、それでいて似たデータコンテナやコード片を使い回せるようにしてコード記述量を極小化したい。そうしないとクエリの可読性が向上せず、メンテナンスが大変になる。