gsql API 設計: GORM / sqlx / squirrel との差分
Go の SQL ビルダー領域に gsql を出すにあたり、既存 OSS とどこで差を付けたか。型安全 × codegen 不要 × zero-value 問題への姿勢を実装ベースで整理する。
なぜもう一つ書くのか
GORM, sqlx, sqlc, ent, sqlboiler, squirrel, goqu, bun, jet, bob — Go の SQL 領域はもう十分に混雑している。私がそこに gsql をもう一つ出すとき、最初に自分に問わなければならなかったのは「書く理由」の方だった。
答えは一行で言える。型安全を、コード生成なしで欲しかった。 GORM は型安全ではない。sqlc / ent / sqlboiler は型安全だが codegen が要る。squirrel / goqu はクエリ構造を与えるが文字列 API で、Go のコンパイラはカラム名の typo を一切捕まえない。この狭い隙間が、Go 1.18 で generics が来た瞬間から空席のまま残っていた。gsql はその席に座る。
既存ライブラリの不快な隙間
Go で SQL を書く選択肢は、ざっくり 3 軸で割れる。
| 路線 | 代表 | 型安全 | codegen | ホットパス reflection |
|---|---|---|---|---|
| ORM 寄り | GORM, ent | × (interface{} 経由) |
不要 / 必要 | あり |
| codegen 系 | sqlc, sqlboiler | ○ | 必要 | なし |
| 文字列ビルダー | squirrel, goqu | × (string ベース) | 不要 | なし |
| gsql | — | ○ (generics) | 不要 | なし |
私は受託の現場で GORM と sqlx を両方踏んできて、両方の不快さを知っている。GORM の .Where("age > ?", "eighteen") はコンパイラを素通りして、本番のログで panic になってやっと気付くタイプの間違いを生む。一方 sqlc は綺麗だが、設計初期にクエリを 1 日 20 回書き換えるフェーズで毎回 sqlc generate を挟むのは地味に厳しい。CI のセットアップにも 1 ステップ増える。
Go 1.18 で generics が来てから、「型安全 × codegen 不要」の象限を埋める実装は技術的に可能になっていた。だが主要ライブラリはどこもここに踏み込んでいなかった。gsql はその空席を狙い撃ちで取りにいったプロダクトだ。
型安全を generics でやる
gsql の API の中心にあるのは Col[T] と Table[C] の 2 つの generic 型だ。カラムが Go の型を持つので、型ミスは全部コンパイル時に潰れる。
type Col[T any] struct {
table string
column string
}
type UserColumns struct {
ID qb.Col[int64] `db:"id"`
Name qb.Col[string] `db:"name"`
Age qb.Col[int] `db:"age"`
}
var Users = qb.NewTable[UserColumns]("users")
// これはコンパイル通る
q := qb.Select(Users.Cols.ID, Users.Cols.Name).
From(Users).
Where(Users.Cols.Age.Gt(18))
// これはコンパイルエラー: int の Col に string を渡している
// Users.Cols.Age.Eq("eighteen")
ここで一つ、現実的な妥協が要る。db:"id" というカラム名の文字列だけは、Go のコンパイラには検証しようがない。最終的にデータベースが「そんなカラムない」と返してくるしかないし、それは sqlc を除く全部のライブラリで共通だ。gsql は NewTable() 初期化時に [A-Za-z_][A-Za-z0-9_]* の正規表現で識別子をバリデーションし、不正な名前ならその場で panic させる。文字列識別子に対する SQL injection 境界はここに引いた。
reflection は NewTable() で 1 回だけ 走り、それ以降のクエリホットパスでは一切使わない。Build() も Insert().Set(...) も全部 generics で動く。ベンチで gsql の SelectSimple が 272 ns/op、GORM が 1715 ns/op と 6 倍速くなっているのは、この「初期化 1 回だけ reflect、あとは型」の設計が効いていると感じている。
zero-value 問題に名前を付ける
Go で部分更新を書いたことがあれば、一度はハマる問題がある。UPDATE users SET age = 0 を意図して送りたい時と、「age は触りたくない」と言いたい時を、Go の zero value だけでは区別できない。
GORM は構造体のゼロ値を「未指定」と解釈して UPDATE から外す。便利な日もあるが、「年齢を 0 にする」が黙って捨てられる事故を生む。ポインタで nullable を表現する手も昔からあるが、コードがポインタだらけで荒れる。
gsql ではこの曖昧さに Optional[T] というラッパーで名前を付けた。
optName := qb.Set("Alice") // SET 句に含める
optAge := qb.Unset[int]() // SET 句から外す
err := qb.Update(Users).
Set(qb.ValIf(Users.Cols.Name, optName)).
Set(qb.ValIf(Users.Cols.Age, optAge)).
Where(Users.Cols.ID.Eq(int64(1))).
Exec(ctx, db)
// → UPDATE users SET name = ? WHERE users.id = ?
// age は SET 句に出てこない (現在値が維持される)
qb.Set(0) は「0 に更新する意図」として SET 句に入る。qb.Unset[int]() は「触るな」として外れる。zero value の意味付けを言語仕様の偶然ではなく、ライブラリ側で名前を付けて固定した。これは地味に効いている設計判断だと思っている。
削ったものの方が多い
gsql の設計で時間を使ったのは「足す」より「足さない」の方だった。
意図的に入れていない:
- eager loading の魔法: has_many は明示的に 2 クエリ書かせる。N+1 を勝手に解決しない
- 結果スキャナー (
Fetch[T]): ホットパスを reflection-free に保つために削った。rows.Scan(...)を直接書いてもらう - 動的なカラム名選択:
OrderByName(string)やWhereRaw(string)は無い。識別子はソースコードリテラルだけ受け付ける - マイグレーション・コネクションプール・リトライ:
database/sqlドライバの責務として外に出す
まだ入れていない:
- subquery, CTE, window 関数, 集約 (COUNT/SUM/AVG/GROUP BY/HAVING)
- raw SQL エスケープハッチ
集約系がまだないのは正直に言って痛い。実用で必要になった時は素の db.QueryContext(ctx, "SELECT COUNT(*) ...", args...) に落とすしかなく、これは半端な状態。ロードマップに載せている。
外したことで失う表現力はある。けれど「型安全 × codegen 不要 × ホットパス reflection-free」のラベルを保つには、ここの線は譲らないと決めている。
自分が gsql で確認したかったこと
gsql は私が「Pure Go の OSS ライブラリ」枠で出しているプロダクトの 2 本目で、1 本目の gpdf と並ぶ位置にある。gpdf でやった検証は「ゼロから書いた PDF ライブラリで CGO なしの世界が成立するか」だった。gsql でやりたかった検証は別で、Go 1.18 の generics が 商用品質の SQL ビルダーを codegen なしで支えられるか を確かめたかった。
ベンチで GORM の 6 倍速、bun と並ぶ数字が出たとき、技術的な妥当性については答えが出たと感じている。残っているのは API surface の好みの問題で、Optional[T] の人間工学はまだ動かしている最中。半年運用してから書き直すかもしれない。
ライセンスを最初は MIT で出すことに決めた経緯は、gpdf と同じ判断軸で動いている。詳しくは Pure Go の micro SaaS を兼業で出している現在地 に書いた。設計判断を物語に寄せた読み物が必要なら Pure Go で zero-dependency の PDF ライブラリを作った話 の方を先に読んでほしい。
実装は gsql の README とリポジトリにある。Optional[T] の妥協点はまだ動かしている最中なので、触ってみて気になったら issue で投げてもらえると助かる。