← 一覧へ戻る

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 で投げてもらえると助かる。

© 2026 nadai · 日本Set in Instrument Serif · Inter