Guide / Reinhardt Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

好きな言語でORMを作るには?

この記事の内容

好きな言語でORMを作る仕組みを調べます。 この動画ではMoonBitを実装例に使い、Reinhardtのモデル対応付けとクエリの核を再現します。小さく動く核を作り、好きな言語へ移す際に保つデータ構造と挙動の検査を説明します。

RustとMoonBitの実験をダウンロード。展開してframework-internals/README.mdに従ってください。Rust 1.96.0、Moon CLI 0.1.20260827、moonc 0.10.11+6ff76a5f9、Node 22.22.1、Python 3.12、ネイティブのCツールチェーンで検証しています。初回ビルドでは依存を取得します。SQLiteの実験はそれぞれ別のメモリ内DBを使い、サーバーは不要です。

python3 verify.py

1つの名前を両方の実装でたどる

名前がSQLのような文字列でも、保存したユーザーを型のある値として取り出したいとします。 Reinhardtはその名前を通常のデータとして返し、MoonBitの再現実装も同じ行を返します。 実際のRustのモデルとクエリをたどり、この動作を実現する経路を作ります。

参照するのはReinhardt 0.4.0-alpha.13で、依存のロックファイルも保持しています。 対象は1つのモデルの対応付けと実行経路で、フレームワークのDB機能全体ではありません。

実際のモデルマクロから始める

整数のIDと名前を持つUserから始めます。 Rustの例ではReinhardtのmodel属性を使い、モデルの契約やフィールド情報を生成します。 その情報が、このアプリの値に対応するテーブルと列をORMに伝えます。固定したソースのModelの契約で確認できます。

Managerはその対応を使ってユーザーを作り、QuerySetは取り出す行の条件を表します。 クエリを記述する段階と実行する段階は別で、フィルターを追加しただけでは行を取得しません。Manager::create_with_connQuerySet::filterを参照してください。リレーションやスキーマの自動生成を加える前に、1つのUserがその経路を通る様子を追いましょう。

マクロで繰り返しの宣言を減らせても、生成した情報がテーブルや復元する値と一致する必要は残ります。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

#[model(app_label = "internals", table_name = "users")]
#[derive(Serialize, Deserialize)]
pub struct User {
    #[field(primary_key = true)]
    pub id: i64,
    #[field(max_length = 100)]
    pub name: String,
}

値がバインドされるまでを追う

今回のフィルターは、name列とアプリから渡された文字列を比較します。 Reinhardtは選んだDB向けのクエリを作り、SQLの構造とは別に値を渡します。 プレースホルダーが値の位置を表し、実行時にドライバーが名前全体を束縛します。QuerySet::all_with_dbでは、SQLとパラメーターの値を分けて実行器へ渡しています。

そのため、名前の中の引用符やテーブル削除に見える命令もデータのまま扱われます。 プレースホルダーはテーブル名や列名を表せないので、それらは信頼するモデル情報から取得します。 返された行はフィールドの復元処理を通り、Userになるか、復元エラーになります。all_with_dbの行からモデルへの変換DatabaseField::decode_databaseの契約で確認できます。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

let rows = QuerySet::<User>::new()
    .filter(Filter::new(
        "name",
        FilterOperator::Eq,
        FilterValue::String(literal.into()),
    ))
    .all_with_db(&mut db)
    .await?;
assert_eq!(rows.len(), 1);

実際のデータベースの境界を観測する

付属の比較コマンドで、RustのプログラムとMoonBitの実行例を動かします。 両方ともAliceと、名前にSQLのような文字列を含む2人目を保存します。 2人目の名前で絞ると、IDが2で元の名前を持つユーザーだけが返ります。

存在しないIDではユーザーは返らず、行がないことと実行失敗を区別できます。 次のトランザクションでは3人目を挿入した後、意図的に存在しないテーブルへ書き込みます。 処理は失敗し、3人目は残らず、元の2人は維持されます。

これらはassertで確認したDBの観測結果で、性能比較ではありません。

Rust / Reinhardt

model=generated manager=Reinhardt queryset=Reinhardt
quoted_input=literal matching_id=2 missing_id=None
failed_transaction_rolled_back=true rows=2
PASS reinhardt-web=0.4.0-alpha.13; real SQLite executor

MoonBit

model=descriptor manager=MoonBit queryset=MoonBit
quoted_input=literal matching_id=2 missing_id=None
failed_transaction_rolled_back=true rows=2
PASS MoonBit descriptors, lazy filters, bound values and typed rows

生成されたモデル情報をMoonBitで表す

この経路を再現するには、生成された契約と、それを作るRustの構文を分けて考えます。 MoonBitのModelは、テーブル名、列名、エンコードとデコードの関数を持つ記述データです。 QuerySetはその記述データとフィルターの配列を持ち、フィルターを加えると新しい記述を返します。

allを呼んで初めてSQLを組み立て、値を束縛し、実行して返された行を復元します。 enumで選べるフィールドを制限し、デコーダーで行に期待した型の値があるか確認します。 MoonBitのデコーダー検証では、不正な行をエラーにし、文字列を黙って整数の識別子へ変換しません。

これは対応付けの契約を手書きで再現したもので、MoonBitのモデルマクロを実装したものではありません。

実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。

priv struct Model[T] {
  table : String
  columns : Array[String]
  encode : (T) -> Array[Json]
  decode : (Json) -> T raise
}

トランザクションの所有者を明確にする

クエリ層は、呼び出し元から渡された接続やトランザクションを使う必要があります。 Reinhardtでは、create_with_connがatomicコールバック内の実行器を受け取ります。固定した実装とトランザクションの使用例で、呼び出し元が接続を渡す境界を確認できます。MoonBitの関数も1つのDBハンドルを受け取り、失敗する挿入を同じトランザクションに含めます。

小さなJavaScriptアダプターはNodeのSQLite操作を公開し、クエリやロールバックの方針はMoonBitに置いています。ホスト言語との境界はMoonBitのFFIドキュメントを参照してください。両者は異なるDBアダプターを使うため、動作が一致しても速度やメモリ確保のコストが同じとは言えません。 別のバックエンドを調べる場合も、その境界を明示しましょう。

この実験ではホストから行をJSONで渡します。追加した表現の境界でもデコーダーが期待する型を保つ必要があり、Reinhardtのネイティブな行形式と同一ではありません。

検証した契約を1つ拡張する

ダウンロードには両方の実装と、ソースから再現実装への対応表を含めています。 比較が通る状態から始め、nullを許すフィールドを1つ加え、行がない場合とnullの違いを定義してみてください。 MoonBitのデコーダーを変える前に、対応するReinhardtのコーデックをたどります。

今回の範囲は2列、範囲を限定した整数ID、等価フィルターで、リレーションや接続プールは含みません。 フレームワークへ違いを報告するときは、バージョン、コマンド、失敗する最小限のassertを残してください。 Reinhardt Pressはプロジェクト自身の広報媒体です。この例を使って、その実装を調べられるようにしています。

好きな言語へ仕組みを持ち帰る

好きな言語では、レコードと変換関数でモデルを表し、リストにフィルターを保持し、値を分けてバインドできるDBドライバーを呼び出します。

同じ検査を保ちます。引用符を含む名前がそのまま返り、存在しないIDにはユーザーが返らず、失敗したトランザクションでは元の行が残ることを確認します。

ソースの対応と検証の限界

PORTING.mdに、固定したフレームワークの関数と実行できるMoonBit実装の対応、および省略した機能を記載しています。ランタイムの表現は異なり、完全な移植ではなく範囲を限定した調査です。Node 22のSQLite APIではexperimentalの警告が出ます。

Reinhardtへの貢献手順