Micro-ORMとテーブルのクラス定義自動生成について
- 2013-06-30
謎社のデータアクセスはMicro-ORMでやっています。生SQL書いて、シンプルなPOCOにマッピングするだけの。ですが、そこで困るのはPOCOの作成。データベースの写しなだけのクラスですが、手で作るには、ひじょーに面倒。Entity Frameworkならドラッグアンドドロップで!DataSetですらホイホイと作れるのに、100%手作業とか嫌だよー、200テーブルを延々とクラス作るだけの刺身たんぽぽなんてしてたら死んじゃうよー。
というわけで、Micro-ORM使うなら避けては通れない定義。EFのクラス定義だけ流用しちゃうとか色々と逃げ道も考えられなくもないですが、もしくは数によっては手動で頑張ってしまうのも手ですが、ここは自動生成しましょうの会。
GetSchema
普通にSQLのクエリを書いてデータベースの情報を取ってくることも可能ですが、各データベースでそれぞれバラバラだったりするので、ここはADO.NETで用意されているGetSchemaを使いましょう。情報取得の部分が抽象化されていて、型無しDataTableとして受け取ることが可能です。
using (var conn = new MySqlConnection("接続文字列。MySQLでもなんでもいいよ。"))
{
conn.Open();
var schema = conn.GetSchema();
}
さて、schemaとやらをデバッガで見てみるとですね……
うおおお、これがDataTableか!な、なんだ、このデバッガビリティの低さは……。これはヤヴァい。マジキチ。RowsのKeyを辿るのも苦労するうえに、Valueが一覧で見れない。頑張ってもKeyだけ。なんだこりゃ。データの取得もLINQ to DataSet(笑)によって、普通に実に扱いづらい。話にならない。 クソが。というわけで、今時ならば型無しDataTableはdynamicで扱ったほうが楽です。ExpandoObjectに変換しましょう。
public static class DataTableExtensions
{
/// <summary>DataTableの各RowをExpandoObjectに変換します。</summary>
public static IEnumerable<dynamic> AsDynamic(this DataTable table)
{
return table.AsEnumerable().Select(x =>
{
IDictionary<string, object> dict = new ExpandoObject();
foreach (DataColumn column in x.Table.Columns)
{
var value = x[column];
if (value is System.DBNull) value = null;
dict.Add(column.ColumnName, value);
}
return (dynamic)dict;
});
}
}
こんなものを用意すると、 var schema = conn.GetSchema().AsDynamic() とするだけで
うおおおおおおお、超捗る!ちゃんと動的ビューでKeyとValueが見える!ExpandoObjectありがとう。DataTableは死ね。また、DBNullをフツーのnullに変換したりなどもしているので、データを触るのもかなり捗るといったところもあります。item.Field<string>("CollectionName")と書くよりも、item.CollectionNameって書きたいですから。
では、気を取り直してこれで解析していきましょう。まずは、件のCollectionNameを見てみますか。
// MetaDataCollections
// DataSourceInformation
// DataTypes
// Restrictions
// ReservedWords
// Databases
// Tables
// Columns
// Users
// Foreign Keys
// IndexColumns
// Indexes
// Foreign Key Columns
// UDF
// Views
// ViewColumns
// Procedure Parameters
// Procedures
// Triggers
foreach (var item in conn.GetSchema().AsDynamic())
{
Console.WriteLine(item.CollectionName);
}
MySQLでは以上のデータが取れるようです。この辺は使ってるデータベースによってかなり変わるので、適宜調べながら合わせてみてくださいな。というわけで、それっぽそうなTablesを見てみます。var tables = conn.GetSchema("Tables").AsDynamic(); とすれば
テーブル一覧が取れるようです。で、しかし、今回必要なのはTablesではありません。TablesはほんとーにTableのデータだけなので。今回必要なのは、Columnsです。
var columns = conn.GetSchema("Columns").AsDynamic()
.GroupBy(x => x.TABLE_NAME) // 全てのカラムが平らに列挙されてくるのでテーブル名でグルーピング
.Select(g => new
{
ClassName = g.Key, // クラス名はテーブル名(= グルーピングのキー)
Properties = g
.OrderBy(x => x.ORDINAL_POSITION) // どんな順序で来るか不明なので、カラム定義順にきちんと並び替え
.Select(x => new
{
Name = x.COLUMN_NAME,
Type = x.DATA_TYPE
})
.ToArray()
});
良い感じに作れてきました。さて、クラスを自動生成するのに必要なのは「クラス名」「プロパティ名」「プロパティの型」です。DATA_TYPEだとDBの生の型名、bigintとかvarcharとかC#のデータ型じゃないよー。なので、このまんまじゃダメです。
というわけで、マッピングを用意してあげます。といっても手動でやる必要はなくて、これはGetSchema("DataTypes")で取れます。
// 型名を決めるのに必要なのは
// TypeName(MySQLの型名), DataType(.NETの型名), IsUnsigned
var typeDictionary = conn.GetSchema("DataTypes").AsDynamic()
.ToDictionary(x =>
Tuple.Create((string)x.TypeName.ToLower(), (bool?)x.IsUnsigned ?? false),
x => (Type)Type.GetType(x.DataType));
// MySQLのtinyintはboolとして使われることが多いので、そちらにマッピングしちゃう(不要ならしなくていいです)
typeDictionary[Tuple.Create("tinyint", true)] = typeof(bool);
typeDictionary[Tuple.Create("tinyint", false)] = typeof(bool);
// nullableの場合を考慮する必要があるのでTypeDictionaryは生では使わない
Func<string, bool, bool, string> getTypeName = (dataType, isUnsigned, isNullable) =>
{
var type = typeDictionary[Tuple.Create(dataType.ToLower(), isUnsigned)];
return (isNullable && type.IsValueType)
? type.Name + "?" // 値型かつnull許可の時
: type.Name;
};
TypeName(MySQLの型名), DataType(.NETの型名), IsUnsigned。それにNullableへの対応を組み合わせれば、マッピングできると考えられます。GetSchemaからの情報だけだとNullable対応が苦しくなるので、外にメソッド立てています。
さて、この型定義辞書を使って変換すると、以下のようになります。
var columns = conn.GetSchema("Columns").AsDynamic()
.GroupBy(x => x.TABLE_NAME) // 全てのカラムが平らに列挙されてくるのでテーブル名でグルーピング
.Select(g => new
{
ClassName = g.Key, // クラス名はテーブル名(= グルーピングのキー)
Properties = g
.OrderBy(x => x.ORDINAL_POSITION) // どんな順序で来るか不明なので、カラム定義順にきちんと並び替え
.Select(x => new
{
Name = x.COLUMN_NAME,
// unsignedの判定はCOLUMN_TYPEから、nullableの判定はYES/NOで行われる
Type = getTypeName(x.DATA_TYPE, x.COLUMN_TYPE.Contains("unsigned"), x.IS_NULLABLE == "YES")
})
.ToArray()
});
これで完璧!さて、定義の抽出はできたので、次はテンプレート作りにいきましょうか。
T4
(テキストとしての)C#コード生成は、C#コード上で文字列を切った貼ったする、わけは勿論ありません。この手の作業するときはテンプレートエンジンを使うのが良いでしょう。最近だとRazorを使ったRazorEngineなどもあるのですが、RazorはあくまでHTML/XMLを出力するのに向いている構文で、C#コードを出力するような用途で使うのは、あまり向いていません。ここは素直にVisual Studio標準のT4 Templateを使うのが良いでしょう。あえてStringTemplate.NETとか、他のを選ぶ理由は、ないかなぁ。T4でいいですよ。
T4にはVisual Studioと連携して保存時にテンプレートが当てはまったテキストを出力するタイプと、ふつーのクラスとして、実行時に任意の変数をあてて、テキストを生成するもののニタイプが選べます。このブログでも何度か紹介してきたのは、全て前者でしたが、今回は後者のパターンを使います。
昔の名前は「前処理されたテキストテンプレート」でした。VS2012から名前変わって「ランタイムテキストテンプレート」になったようです。見つからなかったら検索ウィンドウにT4と入れると良いですよ。では、まず、TableGeneratorTemplate.ttとしてテンプレートを定義します。
<#@ template language="C#" #>
<#@ assembly name="System.Core" #>
<#@ import namespace="System.Linq" #>
<#@ import namespace="System.Text" #>
<#@ import namespace="System.Collections.Generic" #>
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
public class <#= ClassName #>
{
<# foreach(var x in Properties) {#>
public <#= x.Type #> <#= x.Name #> { get; set; }
<# } #>
public override string ToString()
{
return ""
<# foreach(var x in Properties) {#>
+ "<#= x.Name #> : " + <#= x.Name #> + "|"
<# } #>
;
}
}
テンプレートの記法としては、#=で囲むだけなので、まぁそう難しいものでもないです、読みづらさはかなりありますが。さて、このままだとClassNameとかPropertiesとかいうのは未定義でコンパイルもできないので、パーシャルクラスを作ります。クラス名はテンプレートと同名で。
public partial class TableGeneratorTemplate
{
public string ClassName { get; set; }
public IEnumerable<dynamic> Properties { get; set; }
public TableGeneratorTemplate(string className, IEnumerable<dynamic> properties)
{
this.ClassName = className;
this.Properties = properties;
}
}
これで、パラメータを渡せるようになりました。コンパイルエラーも出ません。というわけで実際に出力しましょう。テンプレートをnewして、TransformTextを呼ぶだけです。
// あ、ちなみにここまでのはConsoleApplicationでの話でした、はい。
// テーブル名.csにテンプレートを当てて全部出力
foreach (var item in columns)
{
var tt = new TableGeneratorTemplate(item.ClassName, item.Properties);
var text = tt.TransformText();
File.WriteAllText(item.ClassName + ".cs", text, Encoding.UTF8);
}
これで、以下の様なファイルがテーブル数だけ出力されます。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
public class aiueo_test_table
{
public Int64 id { get; set; }
public String name { get; set; }
public Int32 age { get; set; }
public DateTime? created { get; set; }
public DateTime? modified { get; set; }
public override string ToString()
{
return ""
+ "id : " + id + "|"
+ "name : " + name + "|"
+ "age : " + age + "|"
+ "created : " + created + "|"
+ "modified : " + modified + "|"
;
}
}
やったね!刺身たんぽぽさようなら!ちなみにただのプロパティの塊というだけじゃなく、ToStringも生成しておいてやると、実アプリでのデバッグの時に割と便利ですねー。
応用
GetSchemaで得られる情報は他にも沢山ありますので、Diffを取るプログラムを書けたり、あと、Index, IndexColumnsでインデックスの情報が取れます。インデックスで貼られているものはselectクエリーをほぼほぼ発行するはず、とみなせるので、selectクエリを発行するメソッドを自動生成しちゃったりとかは、実際に謎社ではしています。
つまり作業手順としては、どちらかというデータベースファーストになります。結局、コードとデータベースは違うので、データベース優先の定義・作業のほうが、どこまでいっても自然かな、と。(EF)コードファーストは私は幻想だと思っています。別に、SQL Server Management StudioなりHeidiSQLなどのツールでぽちぽち定義作るのは、そう面倒なわけでもない。そこからC#側のクラス定義も自動で生成できるのなら、むしろ、無理のあるコードでのデータベース表現をして回るよりも、結局、楽じゃない?DB定義をC#側から発行されて、どーのこーとか、なんてのに気を使わなくてもいいので、ずっと楽ちんだと思うんだ。原始的で全然スマートじゃないようで、実利はこっちにある。
まとめ
コードの詳細はMySQLなので、SQL Serverとかじゃ100%そのままは動かないかもですが、その辺は適宜調整してください、きっと似たようなのはあるはずなので。
あと、GetSchemaのDataTableで何が取れるのか、どんなフィールドがあるのかって、特にMySQLだとドキュメントゼロなのですが、そこでAsDynamicは本当に死ぬほど役に立ちました。Visual Studio上でのデバッガビリティを高めるの超大事。その辺がクソなのがDataTableの嫌なところですねえ。今時DataSetを使いまくってるレガシー会社とかあると悲しいですねえ。
ともあれ、dynamicはかなりデバッガビリティ高いので、活用してあげると良いです。dynamic、最近だと忘れ去られているC#の機能らしいので(笑)まあ、メインには使いませんけれど、あるとやっぱ便利なので、あって良かったなって、思いますよん。