Guide / Reinhardt reinhardt-query 0.3.15

Rustの型は、SQLの何を保証できるのか

この記事の内容

列名を打ち間違えたクエリがコンパイルされ、ビルダーは SQL を生成します。 SQLite は users.nmae という列がないため拒否します。一方、列の引数を 整数 42_i32 に変えると、Rust のコンパイラが拒否します。 どちらもダウンロードできるサンプルで 確認した結果ですが、検出される境界が違います。

この記事は、クエリビルダーを使うときに何をテストするか考えたい Rust 開発者に 向けたものです。reinhardt-query =0.3.15 に固定し、公開されたクレートが記録する コミットは c64a11eです。 Reinhardt Press は、Reinhardt 自身の開発者向け広報です。 この実験ではクエリビルダーを直接使い、ORM モデルやマイグレーションは扱いません。

コンパイラが確認するのは API の型

次の抜粋は、意図的にコンパイルが失敗する例です。

Query::select().column(42_i32);

記録された診断は E0277 で、i32: IntoColumnRef が満たされないという内容です。 コンパイラは i32Iden を実装しないことも示します。 固定バージョンの column の定義は、 列参照への変換を要求します。文字列はこの変換を実装しているので、打ち間違いを 含む文字列も渡せます。この API 呼び出しから、コンパイラへ実際の DB スキーマは 渡されません。

Query::select()
    .column(("users", "nmae"))
    .from("users")
    .build(SqliteQueryBuilder);

こちらはコンパイルでき、SELECT "users"."nmae" FROM "users" を生成します。 DB は no such column: users.nmae と報告します。テーブル名で修飾しているのは、 SQLite の二重引用符付き文字列に関する旧来の動作を 避けるためです。この結果は、指定した API とバージョンについてのものであり、 すべての Rust クエリライブラリに共通する主張ではありません。

正しいクエリを DB の実行までたどる

let mut select = Query::select();
select
    .column(("users", "name"))
    .from("users")
    .and_where(Expr::col(("users", "age")).gte(25_i32));
let (sql, values) = select.build(SqliteQueryBuilder);

SQL は SELECT "users"."name" FROM "users" WHERE "users"."age" >= ? となり、 別の値リストに整数 25 が入ります。データは31歳の Ada と24歳の Lin です。 Python 標準ライブラリを使う小さな橋渡し処理が、生成された SQL と値をそのまま sqlite3.Connection.execute に渡し、返る行が Ada 一つであると検証します。 この橋渡し処理は、実験に使う整数と文字列だけを扱うもので、本番用の DB アダプターではありません。

別のクエリでは、名前として Ada' OR 1=1 -- を値に渡します。 結果は0件で、もとのユーザー二人は残ります。これは、実際に通した パラメーター化経路で、値と SQL の構文が分離されていることを確認する例です。 すべての API のセキュリティ監査ではなく、未信頼の入力を raw SQL や識別子に 入れてよいという意味でもありません。

ビルダーの実行中に確認されるもの

let mut insert = Query::insert();
insert.into_table("users").columns(["name", "age"]);
let result = insert.values(vec!["Ada".into()]);

このコードはコンパイルできます。実行時に values が返すエラーは次のとおりです。

Number of values (1) doesn't match number of columns (2)

サンプルは SQLite に INSERT を送らずに、この結果を検証します。 固定バージョンの実装は、 宣言した列が空でないときに個数を調べます。これはビルダーの実行時の検査です。 Result を通常のエラーとして処理できます。別に用意された values_panic には panic する経路があります。

渡せる値でも、その SQL の位置で意味が通るとは限りません。このバージョンでは .limit("ten") がコンパイルされ、LIMIT に文字列の値を渡す SQL を生成します。 SQLite は実行時に datatype mismatch で拒否します。SQL を生成できたことは、 対象の DB で実行できることの証明にはなりません。

DB の保存方針でも結果が変わる

Rust のソースから二つの INSERT を生成し、どちらも age INTEGER 列へ文字列 old を値として渡します。スキーマの方針の違いは STRICT の有無です。

CREATE TABLE ages_loose (age INTEGER);
CREATE TABLE ages_strict (age INTEGER) STRICT;
実行したケース観測結果
通常の INTEGER 列old を保存し、typeof(age)text
STRICT の INTEGER 列ages_strict.age への TEXT を拒否し、テーブルは空のまま

これは SQLite 3.50.4 で実際に確認した結果です。どちらの入力も Rust の文字列としては 正しい型を持ち、DB が最終的な保存規則を適用します。実行スクリプトは、 STRICT テーブルが利用可能になった SQLite 3.37.0 以降を要求します。この比較は SQLite の二つの保存方針についてのもので、 PostgreSQL や MySQL での実行比較ではありません。

方言の生成確認と、DB 間の実行確認

同じ正しい SELECT について、サンプルは次の三種類の SQL 全体と、それぞれの値 [25] を個別に検証します。

バックエンド条件式と識別子の引用符
SQLite"users"."age" >= ?
PostgreSQL"users"."age" >= $1
MySQL`users`.`age` >= ?

ビルダーのインターフェースは SQL と値を返します。ここで実行するのは SQLite 向けの出力だけです。 PostgreSQL と MySQL は生成時の動作を確認しており、その意味や挙動を検証するには、 各 DB のエンジン、バージョン、スキーマ、実行結果が必要です。

サンプルを動かし、回帰テストの境界を選ぶ

サンプルを展開して実行します。

cd rust-sql-guarantees
python3 verify.py

必要なのは Rust 1.96.0 以降、Python 3.11 以降、SQLite 3.37.0 以降にリンクした 標準ライブラリの sqlite3 です。追加の Python パッケージや DB サーバーは不要です。 Cargo が同梱のロックファイルに従って依存を取得します。 2026年9月5日の検証環境は Rust 1.96.0、Python 3.12.12、SQLite 3.50.4 でした。

コマンドは正しいプログラムを実行し、意図したコンパイル失敗を確認してから SQLite で実行し、検証に通った結果を JSON で出力します。 正常な行結果、存在しない列、値と列の個数不一致、文字列 LIMIT の拒否、通常と STRICT の保存動作、文字列のバインド、三方言の SQL と値を検証します。 バージョン、コマンド、ソースとロックファイルのハッシュも記録します。 アーカイブは別の場所へ展開し、展開したコピーでもこのコマンドを実行しています。

保証する境界にテストを置きます。渡せない Rust 引数にはコンパイル失敗テスト、 ビルダーのエラーには実行時の検証、出力には SQL と値の明示した期待値、 DB の意味には実際のエンジンでの実行を使います。 入力を一つ変え、どの段階で拒否されるか予想してください。エラーが分かりにくければ 最小の再現例を残し、貢献ガイドに 従って、診断メッセージの改善や別の DB でのテストを提案できます。 ソース、ロックファイル、コマンド、DB とバージョン、期待値、実測値を含めると再現しやすくなります。 この例ではネットワーク時間、性能、並行書き込みを測定していません。

動画は記録した結果を説明する編集済みのアニメーションであり、連続したターミナル録画ではありません。 ナレーションは ElevenLabs Jofra の合成音声、図は Manim Community、コード画像は Carbon を使用しています。 音楽は Kevin MacLeod「Fluidscape」。 配布元CC BY 4.0。抜粋、フェード、音量調整を行い、ナレーションに重ねています。