Programming w/ C# ~ プロパティの型 "定義" の抽出方法 (再訂正・改良版)

プロパティの型 "定義" の抽出方法 (訂正・改良版)

やりたいこと

データを record class 等のコンテナクラスで扱う際、特にそれをデータベースにマップする際に、カラムに対応する各プロパティの正確なデータ型 "定義" をプログラム上で知りたいことがある。
これができるとコードファースト / モデルファースト的にコンテナクラスの情報から CREATE TABLE を実行できる。

問題の背景

C# 標準 ORM の Entity Framework Core ではこの辺りのマッピングがよくできていて、プログラマがやらなければいけないことはあまりない。
Entity Framework Core は ORM としては優秀である一方、複雑な集合演算に弱く、クエリビルダーとしては貧弱であることは否めない。
そこで EntityFramework Core のデータコンテナに対する型安全性を活かしつつ、Dapper や DbExtensions といったクエリビルダーの集合演算に対する柔軟性を併せ持つ、型安全クエリビルダー・ライブラリを構築する。
その際に record class のプロパティ定義から正確なデータ型 "定義" を抽出する必要がある。

難所

これは System.Reflection を用いればできるように見えて、一筋縄ではいかない。プロパティ情報はいちおう typeof(TContainer).GetProperties() とすることで取得できるが、取得した型が string である場合、ヌル非許容参照型 string とヌル許容参照型である string? とを識別できないためだ。.NET 仮想マシンはデータ型としては string? 型は string 型として保持しており、両者の区別がないため、実行コード上では (一見) 識別できないということになる。

var obj = new TContainer();

obj.GetType().GetProperties().Select( p => p.GetValue(obj)?.GetType() );

などとしてもダメ。プロパティのインスタンスから取得しても、そのプロパティに割り付けられている値が string か null かを得るだけでプロパティの型 "定義" 情報を得ることはできない。

知りたいのはコンパイル時の型定義であって、実行時のインスタンス情報ではない。

解決方法

このように記述するとプロパティの型定義を Nullable / Non Nullable の違いを含めて正確に取得できる。
プロパティに対し (Nullable を外した型名、Nullable であるか否か) とタプルにした情報を返す。

  public static (Type Type, bool IsNullable) GetTypeExact(PropertyInfo prop) =>
    prop.PropertyType switch {
      var pType when pType.IsEnum                              => (pType                         , false                            ),
      var pType when pType.IsGenericTypeOf(typeof(Nullable<>)) => (pType.GetGenericArguments()[0], true                             ),
      var pType when pType.IsValueType                         => (pType                         , false                            ),
      var pType                                                => (pType                         , IsNullableReferenceProperty(prop)),
    };

  public static bool IsNullableReferenceProperty(PropertyInfo prop) {
    return (GetNullableFlagFirst_(prop) ?? GetNullableContextFlag_(prop)) switch {
        0    => ! prop.PropertyType.IsValueType, // 参照型なら Null 許容, 値型なら Null 非許容 (C# 7 or former 後方互換)
        1    => false                          , // Null 非許容
        2    => true                           , // Null   許容
        null => throw new NotImplementedException("NullableFlags or Flag argument failed to be obtained."),
        _    => throw new NotImplementedException("NullableFlags or Flag instance is an unexpected value (other than 0, 1 or 2)."),
    };

    // local method ********
    // [Nullable(byte[] NulableFlags = new[] { 0 or 1 or 2 })] 属性から 0, 1, 2, null (属性なし) を抽出する
    static byte? GetNullableFlagFirst_(PropertyInfo prop) =>
      (prop.GetCustomAttributes()
        .FirstOrDefault( a => a.GetType().FullName == "System.Runtime.CompilerServices.NullableAttribute" )
          .As(out var attrib)
          ?.GetType().GetField("NullableFlags")
          ?.GetValue(attrib) as byte[])?[0];

    // [NullableContext(byte Flag = 1 or 2)] 属性から 1, 2, null (属性なし) を抽出する (C# 8 では属性付与されないため非有効)
    static byte? GetNullableContextFlag_(PropertyInfo prop) =>
      prop.DeclaringType!.GetCustomAttributes()
        .FirstOrDefault( a => a.GetType().FullName == "System.Runtime.CompilerServices.NullableContextAttribute" )
        .As(out var attrib)
        ?.GetType().GetField("Flag")
        ?.GetValue(attrib) as byte?;
}

public static class Extension {
  // 後方参照用中間変数定義ユーティリティ・拡張メソッド
  public static T1 As<T1>(this T1 target, out T1 val1) =>
    val1 = target;
}

あとは下表のように、得られた型と Nullable / NonNullable 情報を元にデータベースの型へマップしてやるだけ。

C# PostgreSQL
int INTEGER NOT NULL
int? INTEGER
double DOUBLE PRECISION NOT NULL
double? DOUBLE PRECISION
string TEXT NOT NULL
string? TEXT

ポイント

  • string と string? は、型では識別できないため、System.Runtime.CompilerServices.NullableAttribute と .NullableContextAttribute という属性にて識別する。
    • プロパティに NullableAttribute 属性が付されているか否かを調べ、その NullableFlags フィールド (byte[]) の第1要素 *1 を調べる。
      • 0 ならばその要素が値型か参照型かで null 許容/非許容が決まる *2 。値型は非許容、参照型は許容。
      • 1 ならば null 非許容参照型。(string)
      • 2 ならば null 許容参照型。(string?)
    • ただし、NullableAttribute 属性が付されていない場合もある。そのときは当該プロパティを包含するクラスに NullableContextAttribute 属性が付されているか否か *3 を調べ、その Flag フィールド (byte) を調べる。
      • 1 ならば null 非許容参照型。(string)
      • 2 ならば null 許容参照型。(string?)
    • プロパティの NullableAttribute 属性の前にクラスの NullableContextAttribute 属性で定義されている *4 ということなのだろう。
  • ヌル許容値型 (int? など) には属性 NullableAttribute は付与されない。
    • 値型の Nullable / NonNullable 判定には .IsGenericTypeOf(typeof(Nullable<>)) を用い、nullable の原型判定には .GetGenericArguments()[0] を用いる。
  • 属性 NullableAttribute はプロパティに、属性 NullableContextAttribute はそれを包含するクラスに付与され存在しうるが、当該属性はシステム管理用であるため、プログラマが直接操作することはできない。
    • そのため、.GetCustomAttribute<NullableAttribute>() などとして属性クラス指定で判定することはできず、全属性をいったん文字列化してから NullableAttribute や NullableContextAttribute の存在を判定する。

*1:属性のコンストラクタ第1引数を調べてはいけない。フィールドは常に byte[] 型だが、C# 言語バージョンによってコンストラクタは byte[] だったり byte だったりする。仕様変更があったようだ。

*2:これはおそらく C# 7 以前に対する後方互換性。

*3:NullableContextAttribute は C# 8 では機能しない。C# 8 は Nullable の扱いが不完全であった。

*4:ただ、NullableContextAttribute がどのように決まり、スイッチされるか C# コンパイラ内部仕様は不明。nullable コンテクストを変えていない同一プロジェクト、同一モジュール内において同一のプロパティ定義をしているクラス間でもこの NullableContextAttribute 属性が異なるケースがあった。