🌊

型から見るGORM Gen

2024/12/16に公開

こちらは株式会社エスマットのSMat Advent Calendar 2024 16日目の記事になります。

記事概要

Gormは洗練されたAPIを持つ大変優れたORMなのですが、いろんな関数の引数の型がinterface{}になっており、静的型付きコンパイル言語のライブラリとしてはやや微妙なところがあります。
GormのコードジェネレータであるGenを使うとinterface{}がなくなるらしいので、APIや定義などを確認したいと思います。

基本的なことは書きませんので、公式ドキュメントやほかのかたの記事をご覧ください。

ドキュメント: https://gorm.io/gen/
GoのORM決定版 Genをはじめよう: https://qiita.com/muff1225/items/f660270694f29597df22

作成されるもの

スキーマから以下のものが生成されるようです。なおディレクトリ名は設定により変わります。

  • model
    • [table_name].gen.go - エンティティを表す構造体とテーブル名を返すTableName()が定義されています。
  • query
    • [table_name].gen.go
      • DBテーブルごとのDAOがパッケージプライベートで生成されます。I[table_name]Doインタフェースとその実装が定義されています。(最後に追記あり)
    • gen.go
      • 上のテーブルごとのDAOを参照するための変数や関数が生成されます。
      • こちらに定義されているSetDefault()やUse()を経由してDAOを使用します。
var (
	Q       = new(Query)
	Company *company
	User    *user
)

func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
	*Q = *Use(db, opts...)
	Company = &Q.Company
	User = &Q.User
}

func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
	return &Query{
		db:      db,
		Company: newCompany(db, opts...),
		User:    newUser(db, opts...),
	}
}

Create

GormのAPIは以下の通りです。

func (db *DB) Create(value interface{}) (tx *DB) {
  // ...
}

valueはエンティティのポインタを渡しますが、定義上はinterface{}になっています。

Genで生成されたAPIは以下の通りです。

func (u userDo) Create(values ...*model.User) error {
	if len(values) == 0 {
		return nil
	}
	return u.DO.Create(values)
}

明示的に*model.Userの可変長引数を渡すようになっています。

Read

Gormの定義は以下の通りです。
Where句は文字列で条件式を書くか、エンティティを渡します。エンティティを渡したときはAND+イコールで検索されます。

//	// Find the first user with name jinzhu
//	db.Where("name = ?", "jinzhu").First(&user)
//	// Find the first user with name jinzhu and age 20
//	db.Where(&User{Name: "jinzhu", Age: 20}).First(&user)
func (db *DB) Where(query interface{}, args ...interface{}) (tx *DB) {
  // ...
}

最初の1件目を取り出すFirst()では、1個目の引数に取得した値を入れるためのエンティティのポインタを渡す必要がありますが、定義上はinterface{}になっています。2個目以降の引数で条件も指定できますが、こちらも同様にinterface{}になっています。

// First finds the first record ordered by primary key, matching given conditions conds
func (db *DB) First(dest interface{}, conds ...interface{}) (tx *DB) {
  // ...
}

GenではWhere()にはgen.Conditionを渡すようになっています。

func (u userDo) Where(conds ...gen.Condition) IUserDo {
	return u.withDO(u.DO.Where(conds...))
}

gen.Conditionの定義はこちらですが、カラムごとの条件を表すオブジェクトになっています。

	Condition interface {
		BeCond() interface{}
		CondError() error
	}

呼び出し時には以下のような記述になり、カラム名を記述する必要がありません。

	qu := query.User
	v, err := qu.Where(qu.ID.Eq(10)).First()

取得する型が自明なため、First()やFind()は以下ように引数がなくなっています。

func (u userDo) First() (*model.User, error) {
  // ...
}

func (u userDo) Find() ([]*model.User, error) {
  // ...
}

Update

Updates(), Save()の定義はGenでも同様に interface{} をとるようになっていて、あまり差はないようです。
GenではUpdate()の定義が変更され、UpdateSimple()が追加されています。

GormのUpdate()ではカラム名は文字列で指定するようになっています。構造体にはカラムの定義が存在するのにカラム名を文字列で書く必要があるのは地味に苦痛ですね。

func (db *DB) Update(column string, value interface{}) (tx *DB) {
  // ...
}

GenのUpdate()はカラムを生成された構造体で指定するようになっています。
値の方は色々あるのでinterface{}になっています。ここはGenericsで特定の値に限定できるかもしれません。

func (d *DO) Update(column field.Expr, value interface{}) (info ResultInfo, err error) {
  // ...
}

Genで追加されたUpdateSimple()ですがカラム名の指定はなく、変更された値を表すオブジェクトを複数渡すことができるようになっています。

func (d *DO) UpdateSimple(columns ...field.AssignExpr) (info ResultInfo, err error) {
  // ...
}

使用方法は以下の通りです(ドキュメントより引用)
Whereで対象レコードの条件を指定して、更新後の値をAdd()やValue()で渡す形式です。
実際にはValue()とかNull()の出番がほとんどだと思います。型もあるしこれが一番わかりやすそうです。
強いていえば特定のテーブルのカラムを表すような定義だとよりうれしいかもしれません。

// Update with expression
u.WithContext(ctx).Where(u.ID.Eq(111)).UpdateSimple(u.Age.Add(1), u.Number.Add(1))
// UPDATE users SET age=age+1,number=number+1, updated_at='2013-11-17 21:34:10' WHERE id=111;

u.WithContext(ctx).Where(u.Activate.Is(true)).UpdateSimple(u.Age.Value(17), u.Number.Zero(), u.Birthday.Null())
// UPDATE users SET age=17, number=0, birthday=NULL, updated_at='2013-11-17 21:34:10' WHERE active=true;

雑感など

Genで生成されたコードには型がついている箇所が増えておりよさそうです。
バックエンドがGormなので何かあればGormの書き方ができるのもよいところだと思います。

またGenはスキーマドリブンなのがよいと思いました。
Gormはスキーマよりコードを先にリリースすると(正確にはクエリの結果にないエンティティのプロパティがあると)実行時エラーになるので、スキーマを先に変更する必要がある仕組みはプロセスが安全になるのでよいと思います。
Gormにはマイグレーション機能があってエンティティの構造体とタグからテーブルを作ったり変えたりできるのですが、開発の導線を考えるとDBのマイグレーションをしてからエンティティをマージ・リリースしたいはずなので、一般的なプロジェクトはどうしているのでしょうか。
過去みたプロジェクト(弊社含む)ではマイグレーションは別の仕組みで行っていました。

公式ドキュメントはシンプルな使い方のみ記述されていて、実際に使うには試行錯誤が必要かもしれません。

やや話題と外れますが、今回SQLiteで試したところGenの開発用DBとして使うのはちょっと厳しいというか、SQLiteは型の種類が少ないのでDBスキーマから取得できる情報が圧倒的に少なく、Genのようにスキーマからコードを生成するタイプのツールから利用するのはけっこう厳しいかもとは思いました。

追記

DAOがインタフェースを持つかどうかは生成時の設定で変わるみたいです。

g := gen.NewGenerator(gen.Config{Mode:gen.WithQueryInterface})

query.SetDefault()gen.Config{Mode:gen.WithDefaultQuery}により生成されるようです。

実アプリケーションだとMode: gen.WithoutContextはしないほうがよいですね。
何も考えずWithContext()を使いましょう。

この辺の設定も有効にしておくとよいです。

	g := gen.NewGenerator(gen.Config{
		FieldNullable:     true, // null許容カラムのときポインタ型にする
		FieldCoverable:    true, // デフォルト値がゼロ値のカラムはポインタ型にする
		FieldSignable:     true, // DBカラムのsigned/unsingedが変数の型に反映される
		FieldWithIndexTag: true, // modelのgormタグのindex節を生成する
		FieldWithTypeTag:  true, // modelのgormタグのtype節を生成する
	})
株式会社エスマット

Discussion