std:db — RDB アクセスのドライバー横断契約
標準ライブラリの契約文書 (index.md)。本書中の裸の §N は本書の節を指す。言語の意味論は ../language-spec.md を参照。
本書は RDB ドライバー (std:db/<driver>) が共通に従う契約 — プレースホルダー・行と値の写像・エラー kind・トランザクション — を定める。実装は db/sqlite.md のみ。将来の std:db/mysql / std:db/postgres (§10) も本契約に乗る。
std:db 自体は import 可能なモジュールとして提供しない (import x := "std:db" は未知モジュールとして静的検査で弾かれる。static-analysis.md §1)。本書は文書上の契約であり、値・型はすべて各ドライバーが export する。ドライバー横断の構造的 interface ({query, exec |} の型メンバー等) を std:db モジュールとして実体化するのは将来枠 (§10)。
import sqlite := "std:db/sqlite" export {} # ハンドルは多重度型なので公開面に出せない ([static-analysis.md](../static-analysis.md#2-静的型検査)) # 列値は Option である (§4)。行の型を宣言すると、読み出しがその型で検査される。 type UserRow := { name: Option(String), age: Option(Int) |} db := sqlite.open("app.db")!.unwrap! db.exec("insert into users (name, age) values (?, ?)", ["Bob", None])!.unwrap! rows: List(UserRow) := db.query("select name, age from users where age > ?", [20])!.unwrap! rows.each { r: UserRow | println "${r.name.unwrap!}: ${r.age.or 0}" } db.close()!
1. スコープと位置づけ
契約が定めるのは「SQL 文字列とバインド値を渡し、行 / 変更数を受け取る」ドライバー水準の最小面である。SQL 方言の吸収・クエリビルダー・ORM・マイグレーションは契約の外で、pkg: エコシステムに委ねる (index.md の能力宣言と同じく、本契約はその契約点になる — std:http の Handler と同じ思想)。
std:db/<driver> の import 自体が「このコードはデータベースに触れる」ことの能力宣言になる。
2. プレースホルダー
バインドは ? (位置指定) に統一する。? の個数とバインド値 List の長さは一致しなければならない (不一致はバグ層 → Error)。
- ドライバーは自 DB の記法へ変換する義務を負う (sqlite / mysql は
?ネイティブ、postgres ドライバーは$Nへ変換する)。利用者コードはドライバーを跨いで同じ SQL 記法で書ける (記法をドライバーごとに変えると、DB を替えるたびに利用者コードの SQL を書き直すことになる)。 - 値の埋め込みはプレースホルダーのみが正道。SQL 文字列への値の連結は injection の温床であり、本契約は連結を助ける機能 (quote 関数等) を提供しない。
- 名前付きプレースホルダーは将来枠 (§10)。
3. 行の表現
query の成功値は 行 record の List (Ok(List(Row)))。各行は SELECT の列順にカラム名をスロット名として持つ closed・immutable なレコードである。
- カラム名は Hikari の識別子 (language-spec.md §1.2) でなければならない。識別子として不正なカラム名 (
count(*)等)・重複するカラム名はError— スロット名規則により識別子以外のスロットは構造上存在できないため。SQL 側でASにより別名を付けるのが正道 (select count(*) as n from t)。 - 0 行は
Ok([])。行の List は全件実体化される (ストリーミングは将来枠 §10)。
4. 値の写像 (読み) — 列値は Option
列値はすべて Option で表す: NULL → None、非 NULL → Some(v) (v の型は各ドライバーの型対応表。sqlite は db/sqlite.md §6)。
row.name.unwrap! # 非 NULL の表明 — 破れたらその場で panic (バグ層) row.age.or 0 # NULL に既定値 match row.age { Some(n) => n.to_string! None => "不明" }
- 「不在がありうる値は
Some/Noneの対で返す」という prelude の規約 (first/to_intほか、prelude.md §12) に列値も従う。NULL 許容列の既定値・変換は Option メソッド (or/map/and_then) で書き、手書きほどきを避ける (prelude.md §12.1 のイディオム)。 - 非 NULL のはずの列には
unwrap!で表明する。想定外の NULL は取り出しの位置で panic になり、値が流れた先の遠い panic にならない。
5. バインド値 (書き)
バインド値 List の各要素は次を受理する:
| バインド値 | SQL 側 |
|---|---|
None |
NULL |
Some(v) |
v を 1 段ほどいた値 (下行) |
生値 v |
ドライバー型対応表の Hikari → SQL 写像 (sqlite は db/sqlite.md §6) |
Some(v)を受理することで、queryで読んだ列値をそのまま次のexecに渡して往復できる (読み書きの対称性)。- Bool は受理しない (
Error)。SQL 標準に可搬な Bool リテラル表現がなく、0/1への暗黙変換は「暗黙の型変換はない」原則 (prelude.md §2) に反する。to_int等で明示変換する。 - 受理集合外の型 (関数・record・List 等) は
Error。
6. exec の結果
exec の成功値は changes (影響行数、Int) スロットを持つ record を最低保証とする。ドライバーは追加スロットで拡張してよい (構造的型の幅サブタイプ。sqlite は last_insert_rowid を加える。db/sqlite.md §4)。
7. エラーモデル
I/O・SQL の失敗は Err({kind, message}) で運ぶ (fs.md §14 と同じ形)。正規 kind (閉じた一覧。全ドライバー共通):
| kind | 意味 |
|---|---|
sql_error |
SQL の構文・実行エラー |
constraint |
制約違反 (UNIQUE / NOT NULL / FOREIGN KEY / CHECK) |
busy |
ロック競合・接続の取得待ちの期限超過 (ネストした transaction の失敗もここ。§9) |
io_error |
その他 (ファイル・接続の I/O 失敗ほか) |
constraintを独立させるのは、重複キー等の「想定内で分岐したい失敗」の代表だからである。- 引数の型・範囲違反、プレースホルダー個数不一致 (§2)、識別子でないカラム名 (§3)、封印後使用は
Error(バグ層、language-spec.md §16)。 - 分岐は
kindで行い、messageの文字列マッチに依存しないこと。
8. ブロッキング I/O と Future
DB 操作はすべてブロッキング I/O であり、Future(Result(...)) を返す (prelude.md §9.4)。! で resolve 値 (Result) を待つ。root 終了時の未解決 Future の扱いは language-spec.md §13.3 のとおり。
9. トランザクション
db.transaction(block) は ブロック式のみを提供する (手動 begin / commit / rollback は提供しない — commit / rollback の対応漏れを構造的に排除する)。
blockはトランザクションハンドルtx(query/execのみを持つ) を 1 引数で受け、Result を返す義務を負う (非 Result の返却はバグ層 → panic)。txは借用としてブロックへ渡る(language-spec.md §17.8)—transaction自身が最後に行う commit/rollback の対象を失わせないためである。この借用の規律は注釈の有無を問わず静的検査に働く —transactionの宣言型が持つブロックパラメーターの型Borrowed(Tx)が、注釈の無いパラメーターへ流れるためである(static-analysis.md §2「注釈の無いブロックパラメーターへ型を流す」)。txを束縛・コンテナーへ格納・別フローへ捕獲するとcannot-bind-borrowedになる。実行時の封印はこれとは独立に効く — block を抜けた後のtxは sealed になり、query/execはtransaction is finishedのErrorになる。blockがOk(v)を返す → commit し、transactionはOk(v)で resolve する (commit 自体の失敗はErr)。blockがErr(e)を返す → rollback し、Err(e)をそのまま透過する (rollback の失敗は結果を差し替えない)。blockの評価が panic する → rollback してから panic を伝播する (!の位置で表面化。language-spec.md §10.2)。- 途中の失敗 bail は
?(language-spec.md §16.5) がそのまま使える —tx.exec(...)!?はErrで block から脱出し、rollback される。 - ネスト非対応。
txはtransactionメソッドを持たない (呼べばError)。block 内で同じDbのtransactionを再度開く試みは、接続の直列化により待ちの期限超過でErr(busy)になる (sqlite は db/sqlite.md §5)。savepoint による部分 rollback は将来枠 (§10)。
result := (db.transaction { tx | tx.exec("update accounts set balance = balance - ? where id = ?", [100, from])!? tx.exec("update accounts set balance = balance + ? where id = ?", [100, to])!? Ok(()) })!
(後置 ! はブロックリテラル自体に結合するため、db.transaction { … } ! とは書けない — 適用全体を括弧で括ってから ! で await する。hikari format の正準形も (db.transaction { … })!。)
10. 将来枠
以下は持たない。
std:db/mysql/std:db/postgres—std:net上の self-host ワイヤプロトコル実装 (net.md §4 が想定してきた路線。postgres の SCRAM-SHA-256 認証にはstd:cryptoへの HMAC-SHA-256 / PBKDF2 追加が前提)std:dbモジュールの実体化 — ドライバー横断の構造的 interface ({query, exec |}) の型メンバー export- 名前付きプレースホルダー / 明示 prepared statement / savepoint (ネストトランザクション) / 行のストリーミング読み
- 型駆動デコード — 行を利用者の
typeへ写し、Option(T)宣言列のみ Option 化する静的な口