プロパティの型 "定義" の抽出方法 (訂正・改良版)
やりたいこと
データを 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 ということなのだろう。
- プロパティに NullableAttribute 属性が付されているか否かを調べ、その NullableFlags フィールド (byte[]) の第1要素 *1 を調べる。
- ヌル許容値型 (int? など) には属性 NullableAttribute は付与されない。
- 値型の Nullable / NonNullable 判定には
.IsGenericTypeOf(typeof(Nullable<>))
を用い、nullable の原型判定には.GetGenericArguments()[0]
を用いる。
- 値型の Nullable / NonNullable 判定には
- 属性 NullableAttribute はプロパティに、属性 NullableContextAttribute はそれを包含するクラスに付与され存在しうるが、当該属性はシステム管理用であるため、プログラマが直接操作することはできない。
- そのため、
.GetCustomAttribute<NullableAttribute>()
などとして属性クラス指定で判定することはできず、全属性をいったん文字列化してから NullableAttribute や NullableContextAttribute の存在を判定する。
- そのため、
*1:属性のコンストラクタ第1引数を調べてはいけない。フィールドは常に byte[] 型だが、C# 言語バージョンによってコンストラクタは byte[] だったり byte だったりする。仕様変更があったようだ。
*3:NullableContextAttribute は C# 8 では機能しない。C# 8 は Nullable の扱いが不完全であった。
*4:ただ、NullableContextAttribute がどのように決まり、スイッチされるか C# コンパイラ内部仕様は不明。nullable コンテクストを変えていない同一プロジェクト、同一モジュール内において同一のプロパティ定義をしているクラス間でもこの NullableContextAttribute 属性が異なるケースがあった。