GORMのGenerics APIは、型パラメータを使ってモデルの型を明示し、結果を戻り値で受け取る操作方法です。従来のようにポインタを渡して詰めてもらう書き方ではなくなるため、エラー処理の形も変わります。この記事のコードはすべてGORM v1.31.2で実行し、出力をそのまま掲載しています。
まとめ
先に要点を示します。
- Generics APIは gorm.G[T](db) から始め、結果を戻り値で受け取る。従来の *gorm.DB を返すチェーンとは戻り値の形が違う
- 取得系は (値, error)、更新・削除系は (影響行数, error) を返すため、RowsAffected と Error を後から参照する必要がない
- すべての終端メソッドが context.Context を第1引数に取る
- 名前が似ているGORM Gen(gorm.io/gen)はコード生成ツールで、Generics APIとは別物
- レコード未検出の判定は従来どおり errors.Is(err, gorm.ErrRecordNotFound) で行える
Generics APIとGORM Genは別物
検索で混ざりやすい2つを先に分けます。Generics APIは、GORM本体に用意された型パラメータ付きの操作方法で、追加のツールもコード生成も要りません。一方のGORM Genは、テーブル定義からクエリ用のコードを生成する別パッケージです。生成物を使う前提で開発フローが変わるため、導入判断の観点も違います。
本記事が扱うのは前者、GORM本体のGenerics APIです。ジェネリクスという言語機能そのものの仕組みはジェネリクスとは|型パラメータの仕組みと主要言語での違いで扱っています。
従来APIとGenerics APIの違い
同じ「名前で1件取得する」処理を並べると、差がはっきりします。
// 従来API:ポインタを渡し、結果は *gorm.DB のフィールドで確認する
var u User
tx := db.Where("name = ?", "sato").First(&u)
fmt.Printf("legacy First: %+v rows=%d err=%v\n", u, tx.RowsAffected, tx.Error)
// Generics API:値とエラーを戻り値で受け取る
u2, err := gorm.G[User](db).Where("name = ?", "sato").First(ctx)
fmt.Printf("First: %+v err=%v\n", u2, err)
従来APIは戻り値が *gorm.DB なので、取得結果は引数で渡した変数に入り、成否は tx.Error を見るまで分かりません。Generics APIは戻り値が (User, error) になるため、Goの一般的な関数と同じ形で扱えます。
| 操作 | 従来API | Generics API |
|---|---|---|
| 1件取得 | First(&u) → *gorm.DB | First(ctx) → (T, error) |
| 複数取得 | Find(&us) → *gorm.DB | Find(ctx) → ([]T, error) |
| 更新 | Update(…) → *gorm.DB | Update(ctx, …) → (int, error) |
| 削除 | Delete(…) → *gorm.DB | Delete(ctx) → (int, error) |
| 影響行数 | tx.RowsAffected | 戻り値の第1要素 |
| コンテキスト | WithContext(ctx) を挟む | 終端メソッドの第1引数 |
コンテキストが必須になった点は運用上の違いとして大きく、タイムアウトやキャンセルの設計を後付けにできません。渡すべきコンテキストの選び方はGo言語のcontext入門|BackgroundとTODOの使い分け、キャンセルとタイムアウトの実装で整理しています。
基本の実装パターン
作成から削除までを一通り書いたものが次のコードです。ドライバはピュアGoのSQLite実装を使い、インメモリのデータベースに対して実行しています。
package main
import (
"context"
"errors"
"fmt"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
type User struct {
ID uint
Name string
Age int
}
func main() {
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
if err != nil {
panic(err)
}
if err := db.AutoMigrate(&User{}); err != nil {
panic(err)
}
ctx := context.Background()
if err := gorm.G[User](db).Create(ctx, &User{Name: "sato", Age: 41}); err != nil {
panic(err)
}
if err := gorm.G[User](db).Create(ctx, &User{Name: "ito", Age: 29}); err != nil {
panic(err)
}
u, err := gorm.G[User](db).Where("name = ?", "sato").First(ctx)
fmt.Printf("First: %+v err=%v\n", u, err)
users, err := gorm.G[User](db).Where("age > ?", 30).Find(ctx)
fmt.Printf("Find: %+v err=%v\n", users, err)
n, err := gorm.G[User](db).Where("name = ?", "ito").Update(ctx, "age", 30)
fmt.Printf("Update: rows=%d err=%v\n", n, err)
_, err = gorm.G[User](db).Where("name = ?", "none").First(ctx)
fmt.Printf("NotFound: is ErrRecordNotFound=%v\n", errors.Is(err, gorm.ErrRecordNotFound))
d, err := gorm.G[User](db).Where("age < ?", 35).Delete(ctx)
fmt.Printf("Delete: rows=%d err=%v\n", d, err)
}
実行結果は次のとおりです。
First: {ID:1 Name:sato Age:41} err=<nil>
Find: [{ID:1 Name:sato Age:41}] err=<nil>
Update: rows=1 err=<nil>
NotFound: is ErrRecordNotFound=true
Delete: rows=1 err=<nil>
Createだけは作成対象をポインタで渡します。生成されたIDを構造体へ書き戻す必要があるためで、ここは従来APIと同じ考え方です。UpdateとDeleteは影響行数が第1戻り値で返るので、1件だけ更新したはずが複数行に当たっていないかをその場で確認できます。
レコードが見つからないときの扱い
Firstは対象が無いとエラーを返します。判定方法は従来と変わらず、errors.Is で gorm.ErrRecordNotFound と比較します。上の実行結果でも true が出力されています。
注意したいのは、Generics APIでは戻り値の第1要素がゼロ値の構造体で返る点です。エラーを確認せずに続行すると、ID=0のユーザーをそのまま使ってしまいます。従来APIでも同じ事故は起きますが、値が戻り値として手元にある分、うっかり使える距離が近くなっています。
どちらを使うべきか
新規に書くコードはGenerics APIを選ぶのが妥当です。戻り値の形がGoの標準的な作法に沿っていて、コンテキストの受け渡しが強制されるためです。
一方、既存コードの一括置換は勧めません。GORMはバージョンによってGenerics API側のメソッド構成が変わっており、置換の途中でコンパイルが通らなくなる箇所が出ます。v1.30.0で削除されたメソッドと移行手順はGORM v1.30.0のジェネリクスAPI変更点|削除されたFirstOrCreate・Saveと移行のポイントにまとめているので、移行を検討する場合は先に確認してください。新規に追加する処理から使い始め、既存部分は触らないほうが安全です。
よくある質問
Generics APIを使うにはどのバージョンが必要ですか?
本記事のコードはGORM v1.31.2で実行を確認しています。メソッド構成はバージョンによって変わるため、採用時はgo.modで固定したバージョンに対して自分でコンパイルを通してください。
GORM GenとGenerics APIはどちらを使えばよいですか?
目的が違います。Generics APIはGORM本体の書き方で、追加ツールなしに使えます。GORM Genはコード生成を前提としたパッケージで、生成物をリポジトリに含める運用が必要です。まずGenerics APIで書き、生成が必要になった時点でGenを検討する順序が扱いやすい構成です。
コンテキストは毎回渡す必要がありますか?
必要です。First・Find・Update・Deleteといった終端メソッドが第1引数にcontext.Contextを取ります。特に理由がなければハンドラから受け取ったコンテキストをそのまま渡します。
影響行数はどう取得しますか?
UpdateとDeleteの第1戻り値がint型の影響行数です。従来APIのようにtx.RowsAffectedを参照する必要はありません。