Go

GenORMとは?Goのジェネリクスで型安全にSQLを組む使い方と制約

GenORMとは?Goのジェネリクスで型安全にSQLを組む使い方と制約

GenORMは、Goのジェネリクスを使ってSQLの書き間違いをコンパイル時に見つけるSQLビルダーです。開発者のmazrean氏が2022年にGitHubで公開し、MITライセンスで配布されています。文字列のカラムを数値と比べる、別テーブルのカラムをWHERE句に混ぜる、といったミスをgo buildの段階で止められます。

この記事では、最新版v1.2.0のソースと公式ドキュメントを確かめたうえで、Go 1.26.5で実際にコードを生成し、ビルドと実行をした結果に基づいて仕組み・使い方・制約を説明します。なお、検索で同じ綴りが出てくるgeNormは、リアルタイムPCRの参照遺伝子を選ぶ解析ツールで、GenORMとは関係ありません。

まとめ:GenORM v1.2.0の要点と採用判断

  • カラムとGoの型、所属テーブルを型パラメーターに持たせることで、型の違う値との比較・代入や別テーブルのカラムの混入をコンパイルエラーにします。
  • 最新版はv1.2.0(2024年11月25日)です。前版からの変更は必要なGoのバージョンを1.18から1.22へ上げたことで、以後のmainブランチへの変更はほとんどが依存ライブラリの更新です。
  • 公式ドキュメントが動作確認済みとしているのはMySQLとMariaDBだけです。プレースホルダーが?固定のため、PostgreSQLでは使えません。
  • v1.2.0のCLIが生成するテーブル別サブパッケージは、github.com/mazrean/genormの二重importでコンパイルできません(Issue #37・未解決)。ルートpackageの識別子を直接使えば回避できます。
  • サブクエリ、マイグレーション、発行するSQLを文字列で返すメソッドはありません。MySQL系で、型安全を最優先する小規模なサービスなら候補になりますが、チームの標準ORMにするなら事前に検証が必要です。

GenORMがコンパイル時に止めるSQLのミス

GORMのように引数をinterface{}で受けるORMは、型の合わない値を渡してもビルドが通り、実行して初めて誤りに気づきます。GenORMは同じ誤りをコンパイラに見つけさせます。以下は、後述の手順で生成したコードに誤りを3つ入れ、Go 1.26.5でビルドしたときのエラーです。

文字列カラムと数値の比較、時刻カラムへの文字列の代入

users.name(string)を1と比べる、users.created_at(time.Time)に文字列を代入する、という2つの誤りは、どちらも型推論の不一致として止まります。

// users.name を数値と比較
genorm.Select(orm.User()).Where(genorm.EqLit(orm.UserName, genorm.Wrap(1)))

in call to genorm.EqLit, type genorm.WrappedPrimitive[int] of genorm.Wrap(1)
does not match inferred type genorm.WrappedPrimitive[string] for S

// users.created_at に文字列を代入
genorm.Update(orm.User()).Set(genorm.AssignLit(orm.UserCreatedAt, genorm.Wrap("2026-10-01")))

in call to genorm.AssignLit, type genorm.WrappedPrimitive[string] of genorm.Wrap("2026-10-01")
does not match inferred type genorm.WrappedPrimitive[time.Time] for S

別テーブルのカラムの混入

usersを対象にしたSELECTのWHERE句にmessages.idを使うと、条件式の型が*orm.MessageTableに属するため、*orm.UserTableを期待するWhereに渡せません。

genorm.Select(orm.User()).Where(genorm.EqLit(orm.MessageID, genorm.Wrap(int64(1))))

cannot use genorm.EqLit(orm.MessageID, ...) (value of interface type
genorm.TypedTableExpr[*orm.MessageTable, genorm.WrappedPrimitive[bool]]) as
genorm.TypedTableExpr[*orm.UserTable, genorm.WrappedPrimitive[bool]] value
in argument to genorm.Select(orm.User()).Where

Defined Typeによる同じ型どうしの取り違え防止

messages.idとmessages.user_idがどちらもuuid.UUIDなら、両者を比べてもエラーにはなりません。公式ドキュメントは、type MessageID uuid.UUIDのように別の型を定義し、sql.Scannerとdriver.Valuerを実装してカラムに割り当てる方法を示しています。こうすると、メッセージIDとユーザーIDの比較もコンパイルエラーになります。IDの取り違えが起きやすいテーブル設計では、この一手間が効きます。

GenORMでも防げない誤り

GenORMは設定ファイルに書いた構造体を正として型を作り、実際のデータベースのスキーマは読みません。設定ファイルにあってテーブルに無いカラムや、NOT NULL制約への違反は、実行時のエラーとして返ります。LIMITの値やWHERE句の論理の誤りなど、型が合っている誤りも検出の対象外です。

型パラメーターでSQLの式を制約する仕組み

中心にあるのはTypedTableExpr[T, S]という型です。Tは式が使っているテーブル、Sはその式に対応するGoの型を表します。たとえばEqLitは次のように定義されています。

func EqLit[T Table, S ExprType](
	expr TypedTableExpr[T, S],
	literal S,
) TypedTableExpr[T, WrappedPrimitive[bool]]

左辺のカラムと右辺の値が同じSでなければ型推論が失敗し、結果は同じテーブルTの真偽値の式になります。Whereは対象テーブルと同じTの真偽値の式しか受け取らないため、前章の2種類のエラーが起きます。

intやstringなどのプリミティブ型は、genorm.WrapでWrappedPrimitive[T]に包んで渡します。この型はNULLかどうかのフラグも持っており、取得した値はVal()で(値, NULLでなければtrue)の組として取り出します。INSERTで値を設定しなかったフィールドはNULLとして書き込まれます。

GenORMの導入とコード生成の手順

CLIとパッケージのインストール

GenORMは、テーブル定義からコードを作るCLIと、クエリを組み立てるパッケージの2つを使います。以下はGoモジュール内で実行する例です。新規プロジェクトでは先にgo mod init example.com/demoを実行し、インストールしたgenormコマンドの配置先にPATHを通してください。READMEのインストール例は@v1.0.0のままなので、版は明示して入れます。v1.2.0はGo 1.22以上が必要です。

go install github.com/mazrean/genorm/cmd/[email protected]
go get github.com/mazrean/[email protected]

テーブル構成を構造体で書く設定ファイル

設定はYAMLやJSONではなく、Goの構造体で書きます。テーブル名はTableNameメソッドで返し、カラム名はgenormタグで指定します。JOINできる相手はgenorm.Ref[T]型のフィールドで表します。生成コードと同じ名前の構造体が並ぶため、公式ドキュメントは本体と別のビルドタグを付けるよう勧めています。

//go:build genorm

//go:generate genorm -source=$GOFILE -destination=ormgen -package=orm -module=example.com/demo/ormgen

package main

import (
	"time"

	"github.com/mazrean/genorm"
)

type User struct {
	ID        int64     `genorm:"id"`
	Name      string    `genorm:"name"`
	CreatedAt time.Time `genorm:"created_at"`
	Message   genorm.Ref[Message]
}

func (*User) TableName() string { return "users" }

type Message struct {
	ID      int64  `genorm:"id"`
	UserID  int64  `genorm:"user_id"`
	Content string `genorm:"content"`
	User    genorm.Ref[User]
}

func (*Message) TableName() string { return "messages" }

カラムに使える型は、bool、各サイズのint・uint、float32・float64、string、time.Timeと、sql.Scannerとdriver.Valuerを実装した型(uuid.UUIDなど)です。同じテーブルを1つのJOINで2回使うことはできません。

go generateのフラグと生成されるファイル

go generate -tags genorm ./...を実行すると、-destinationのディレクトリにコードが出力されます。フラグは次の6つです。

フラグ 必須 指定する内容
-source 必須 設定ファイルのパス
-destination 必須 生成先ディレクトリ
-package 必須 生成先直下のpackage名
-module 必須 生成先ディレクトリのモジュールパス
-join-num 任意 JOINできるテーブル数(既定5)
-version 任意 CLIのバージョンを表示

上の2テーブルの例では、ルートのormgen/genorm.goと、テーブルごとのormgen/user/user.go・ormgen/message/message.goの3ファイル、計314行ができました。公式ドキュメントによると、生成時間は-join-numに対して指数的に増えるため、JOINするテーブルが少ないなら値を下げます。

生成されたサブパッケージがビルドできない問題と回避策

v1.2.0のCLIで上の設定ファイルから生成すると、テーブル別のサブパッケージ(user・message)がgenorm redeclared in this blockでコンパイルできません。生成処理が、設定ファイルのimport宣言に含まれるgithub.com/mazrean/genormに加えて、同じパッケージをもう一度importに足すためです。importを1行で書いた設定ファイルでは、逆にルートpackageのimportが落ちてundefined: ormになりました。v1.1.3とmainブランチ(2026年9月26日時点)のCLIでも出力は同じで、この不具合はIssue #37として2022年3月から未解決です。CIは生成したコードのビルドまでは確かめていません。

回避策は、サブパッケージを使わずルートpackageの識別子を直接使うことです。READMEのuser.NameExprは、生成コードの中でorm.UserNameを代入しただけの変数なので、orm.UserNameをそのまま式としてもカラムとしても渡せます。サブパッケージのディレクトリはgo vet ./...を落とすので、生成のたびに削除するか、importを手で直します。

CRUD・JOIN・トランザクションの書き方

ここからのコードは、前章の回避策どおりorm.UserNameなどのルートpackageの識別子だけを使っています。Go 1.26.5、GenORM v1.2.0、MySQL互換のインメモリサーバーgo-mysql-server v0.20.0の組み合わせで実行し、コメントは実際に発行されたSQLです。

CRUDの実装例と発行SQL

以下はerrorを返す関数の中に置く想定のコードで、uを参照する箇所以外のエラー確認は省略しています。

// INSERT INTO users (users.id, users.name, users.created_at) VALUES (?, ?, ?), (?, ?, ?)
n, err := genorm.Insert(orm.User()).Values(
	&orm.UserTable{ID: genorm.Wrap(int64(1)), Name: genorm.Wrap("alice"), CreatedAt: genorm.Wrap(time.Now())},
	&orm.UserTable{ID: genorm.Wrap(int64(2)), Name: genorm.Wrap("bob"), CreatedAt: genorm.Wrap(time.Now())},
).Do(db)

// SELECT users.id AS users_id_0, ... FROM users WHERE (users.name = ?) LIMIT 1
u, err := genorm.Select(orm.User()).
	Where(genorm.EqLit(orm.UserName, genorm.Wrap("alice"))).
	Get(db)
if err != nil {
	// 0件なら Get は sql.ErrNoRows を genorm.ErrRecordNotFound に置き換えて返す
	// errors.Is(err, genorm.ErrRecordNotFound) で判定できる
	return err
}
name, ok := u.Name.Val()

// SELECT COUNT(users.id) AS res FROM users LIMIT 1
cnt, err := genorm.Pluck(orm.User(), genorm.Count(orm.UserID, false)).Get(db)

// UPDATE users SET users.name = ? WHERE (users.id = ?)
n, err = genorm.Update(orm.User()).
	Set(genorm.AssignLit(orm.UserName, genorm.Wrap("alice2"))).
	Where(genorm.EqLit(orm.UserID, genorm.Wrap(int64(1)))).
	Do(db)

// DELETE FROM users WHERE (users.id = ?)
n, err = genorm.Delete(orm.User()).
	Where(genorm.EqLit(orm.UserID, genorm.Wrap(int64(2)))).
	Do(db)

この例のINSERTの非NULL値や比較・代入の値はプレースホルダー経由で渡されます。ただし、LIMIT・OFFSETの数値やINSERTのNULLはSQL文字列に直接出力されます。SelectにはDistinct・GroupBy・Having・OrderBy・Limit・Offset・Lockがあり、Lock(genorm.ForUpdate)でFOR UPDATEが付きます。一方、genorm.RawExprは任意のSQL断片を式にできるので、ここへ利用者の入力を連結すると型安全もインジェクション対策も失われます。

JOINの書き方とカラムの変換

JOIN後のテーブルは元のテーブルと型が違うため、カラムを~ParseExpr(式として使う場合)や~Parse(取得するカラムとして使う場合)で変換します。

// SELECT users.name AS users_name_0, messages.content AS messages_content_0
// FROM (users INNER JOIN messages ON (users.id = messages.user_id))
rows, err := genorm.Select(orm.User().Message().Join(
	genorm.Eq(orm.MessageUserParseExpr(orm.UserID), orm.MessageUserParseExpr(orm.MessageUserID)))).
	Fields(orm.MessageUserParse(orm.UserName), orm.MessageUserParse(orm.MessageContent)).
	GetAll(db)

JoinのほかにLeftJoin・RightJoinがあり、Join(nil)はCROSS JOINになります。Fieldsで選ばなかったカラムは、結果の構造体ではNULL扱い(Val()の2番目がfalse)です。

トランザクションとcontextの渡し方

実行メソッドが受け取るのは、ExecContext・QueryContext・QueryRowContextの3つを持つgenorm.DBインターフェースです。*sql.DBも*sql.Txもこれを満たすので、トランザクション内ではtxを渡します。

tx, err := db.Begin()
if err != nil {
	return err
}
defer tx.Rollback()

if _, err := genorm.Delete(orm.User()).
	Where(genorm.EqLit(orm.UserID, genorm.Wrap(int64(2)))).
	DoCtx(ctx, tx); err != nil { // db ではなく tx を渡す
	return err
}
return tx.Commit()

READMEのトランザクションの例はDo(db)と書かれており、そのまま写すとINSERTはトランザクションの外で実行されます。実行時のタイムアウトやキャンセルは、GetAllCtx・GetCtx・DoCtxにcontextを渡して制御します。contextの使い方はGo言語のcontext入門で解説しています。

発行されるSQLのログ出力

GenORMには、組み立てたSQLを文字列で返すメソッドがありません(Issue #96で要望されたまま未対応です)。genorm.DBは3メソッドだけの小さなインターフェースなので、ラップして出力するのが手軽です。この記事のSQLのコメントも、この方法で取りました。

type logDB struct{ genorm.DB }

func (l logDB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
	log.Println("SQL:", q)
	return l.DB.QueryContext(ctx, q, args...)
}
// ExecContext と QueryRowContext も同じ形で上書きし、genorm.Select(...).GetAll(logDB{db}) のように渡す

未対応の機能と対応データベース

採用前に確かめておきたいのは、次の未対応項目です。いずれもv1.2.0のソースとIssueで確認した2026年10月1日時点の状況です。

項目 v1.2.0の状況 関連Issue・PR
MySQL・MariaDB 動作確認済み 公式ドキュメント
PostgreSQL・SQLite 未対応 #100、ドラフトPR #205
サブクエリ 未対応 #101、ドラフトPR #206
SQLを文字列で返す 未対応 #96
Prepared Statementの再利用 未対応 #39
マイグレーション 機能なし なし
同じテーブルを2回JOIN 不可 公式ドキュメント

PostgreSQLで使えないのは、プレースホルダーが?に固定されていて、PostgreSQLの$1形式を出せないためです。SQLiteは?を受け付けますが、modernc.org/sqlite v1.60.1で試すと、INSERTの列リストにusers.idのようなテーブル名付きの列名が並ぶ時点でnear ".": syntax errorになりました。マイグレーションは別のツールに任せます。Go製ならGooseの使い方|Go製マイグレーションツールv3.28のコマンドと本番運用が組み合わせやすい候補です。

GORM・entとの違いとGenORMを選ぶ条件

GenORMの公式ドキュメントは、GORMについては「interface{}で柔軟に引数を受ける代わりに型の制約が弱い」と説明し、entについては使用できる型の制限とエンティティ操作への抽象化を挙げています。ただし、現在のentはGoTypeやValueScannerによる独自型に対応しているため、型の対応範囲は現在の両者の差にはなりません。版と規模を並べると次のとおりです(2026年10月1日時点)。

項目 GenORM GORM ent
最新版 v1.2.0(2024-11) v1.31.2(2026-06) v0.14.6(2026-03)
型の検査 カラム・値・テーブル Generics APIで一部 生成コードで検査
クエリの書き方 SQLに近いメソッドチェーン メソッドチェーン エンティティ操作
対応DB MySQL・MariaDB MySQL・PostgreSQLなど MySQL・PostgreSQLなど
マイグレーション なし AutoMigrate あり

GORMもv1.30.0(2025年5月)でジェネリクスを使うAPIを加えており、モデルの型は検査されるようになりました。詳しくはGORMのGenerics APIとは?従来APIとの違いと基本の実装パターンで解説しています。GenORMの特徴は、SQL式に値の型と所属テーブルを型パラメーターとして持たせる設計です。entも生成された型付きの条件関数を使うため、値や対象エンティティの取り違えをコンパイル時に検出できます。

GenORMが向くのは、MySQLかMariaDBを使い、発行されるSQLをコードから読み取れることを重視する、小規模なサービスや社内ツールです。逆に、PostgreSQLを使う場合、サブクエリを多用する場合、長期保守する業務システムの標準にする場合は採用を見送るべきです。GitHubのスター数は51、コミットの大半は作者1人によるもので、v1.2.0以降に機能の追加はありません。生成コードの不具合も手で回避し続けることになります。ORMそのものの利点と欠点はORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説で整理しています。

よくある質問

GenORMはPostgreSQLで使えますか?

v1.2.0では使えません。プレースホルダーが?固定で、PostgreSQLの$1形式を出力しないためです。対応を加えるドラフトのPR #205はありますが、2026年10月1日時点でマージされていません。

GenORMとgeNormは同じものですか?

別物です。geNormはリアルタイムPCRで発現量の安定した参照遺伝子を選ぶ解析ツールで、検索結果の上位はこちらが占めています。GoのSQLビルダーを探す場合は「genorm go」や「genorm golang」で検索すると絞り込めます。

GenORMはORMですか、それともクエリビルダーですか?

名前はORMですが、公式の説明は「SQL Builder」です。リレーションの遅延読み込みや変更の自動追跡はなく、SQLの構文に近いメソッドチェーンでクエリを組み立て、結果を生成された構造体に読み込みます。

NULLが入るカラムはどう扱いますか?

プリミティブ型やtime.TimeのカラムはWrappedPrimitive[T]として読み込まれ、Val()の2番目の戻り値がfalseならNULLです。INSERTで値を設定しなかったフィールドはSQLのNULLとして書き込まれるため、WrappedPrimitiveで扱うNOT NULLカラムにはgenorm.Wrapで値を設定します。uuid.UUIDなどの独自型は直接設定し、未設定時やNULLの扱いはその型の実装を確認します。

GenORMでテーブルを作成・変更できますか?

できません。GenORMは既存のテーブルに対するCRUDだけを扱い、マイグレーションの機能を持ちません。テーブルはGooseなどのマイグレーションツールで管理し、設定ファイルの構造体をそれに合わせて更新します。

関連記事

お気に入りに入れた記事の一覧

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.10.08 テックブログ 大阪公立大学のランサムウェア被害と仮想化基盤の停止|全授業休講に至った経緯とバックアップを守る設定
  3. 2024.11.08 テックブログ OpenAPI GeneratorでJavaコードを自動生成する方法|CLI導入からSpring・ライブラリ選択まで
  4. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順
  5. 2026.10.09 テックブログ 京王電鉄のランサムウェア被害とグループ共通基盤:決済・ポイント・予約が止まった範囲と遮断の初動

RELATED POSTS 関連記事

目次