Go Advent Calendarの枠が空いていたので参加させていただきました。
TL; DR
SQLBoiler のテストコードに使用するために go-mysql-server のテーブル定義ファイルを自動生成
- テンプレート: https://github.com/Syuparn/sqlboiler-practice/tree/main/simulator_models
- 利用箇所: https://github.com/Syuparn/sqlboiler-practice/blob/main/test/scenario_test.go
はじめに
go-mysql-serverを使ってDB周りのテストをせこせこ書いていたとき、ふと思いつきました。
「スキーマに依存するコードは自動生成できるのでは?」
本記事では、SQLBoilerのテンプレート機能を使って自動生成してみた方法を紹介します。
背景
本題の前に、今回使用しているSQLBoilerとgo-mysql-serverについて軽く紹介します。(すでにご存じの方は読み飛ばしてください)
SQLBoiler
SQLBoilerはSQLのORMです。
GoのORMとしてはGORM も有名ですが、こちらは稼働しているDBのスキーマを読み込みコードを自動生成するという特徴を持ちます。
テーブルや列の型に合わせた構造体が生成されるので、型安全にDB操作を実装できます。
さらに、テンプレートを記述することで自動生成ファイルを追加することが可能です。本記事ではこの機能を使って、後述のgo-mysql-serverのスキーマ定義を自動生成します。
go-mysql-server
go-mysql-serverは、インメモリでMySQL互換のDBとして動作するテスト用DBライブラリです。ライブラリ経由でテーブルやレコード操作ができるため、「取得テスト実行前にレコードを挿入」といった使い方が可能です。
// INSERT INTO user (id, name age) values ('0123456789ABCDEFGHJKMNPQRS', 'Mike', 20);
table.Insert(ctx, sql.NewRow(
"0123456789ABCDEFGHJKMNPQRS",
"Mike",
int64(20),
))
テストでの使い方について詳細は過去の記事をご覧ください。
一方で、準備としてライブラリ上でテーブル構造体を逐一定義する必要があります。
「スキーマ定義と2重管理になってるし、こっちを自動生成できないかな...?」というのが本記事の動機です。
tableName := "user"
table := memory.NewTable(tableName, simsql.NewPrimaryKeySchema(simsql.Schema{
{Name: "id", Type: simsql.Text, Nullable: false, Source: tableName, PrimaryKey: true},
{Name: "name", Type: simsql.Text, Nullable: false, Source: tableName},
{Name: "age", Type: simsql.Int64, Nullable: false, Source: tableName},
}), db.GetForeignKeyCollection())
テンプレートの書き方
本題の自作テンプレート実装についてです。話をSQLBoilerに戻します。
SQLBoilerではGo Template(text/template)を使ってコード生成を拡張することが可能です。
デフォルトで生成されるコードもGo Templateを使用しているので、これらをお手本に書くのが良いと思います。
設定
まずは自作テンプレートを読み込むため、 sqlboiler.toml
に設定を加えます。
注意点として、デフォルトの内容を生成するにはデフォルトのテンプレートと自作テンプレートの両方を指定する必要があります。
詳しくはこちらの記事が参考になります。
後述のimport文の問題もあり、今回は設定ファイル自体を2つに分割し、別々にコード生成を行うことにしました。
package main
// デフォルトのテンプレートを models/ へ生成
//go:generate sqlboiler mysql
// 自作テンプレートを simulator_models/ へ生成
//go:generate sqlboiler mysql -c sqlboiler.simulator.toml
# 再生成前に既存の生成物を削除
wipe = true
# 生成ディレクトリ
output = "simulator_models"
# 不要なimport文が残らないように、デフォルトのテンプレートを無効化
no-auto-timestamps = true
no-back-referencing = true
no-context = true
no-driver-templates = true
no-hooks = true
no-rows-affected = true
# 今回はデフォルトのテンプレートを使わないので自作テンプレートのみ指定
templates = [
"templates",
]
# 自動生成ファイルに入れたいimportを指定
[imports.all]
standard = []
third_party = [
'simsql "github.com/dolthub/go-mysql-server/sql"',
'"github.com/dolthub/go-mysql-server/memory"',
'"github.com/volatiletech/null/v8"',
]
[mysql]
dbname = "practice"
host = "localhost"
port = 3306
user = "root"
pass = ""
sslmode = "false"
テンプレート作成
続いて、テンプレートファイルを記述します。組み込み関数に加え Sprig の関数が使用可能です1。
生成時に使用できるオブジェクトは以下の通りです。
注意点として、テンプレートファイルを分けても、テンプレートに実装した内容は特定のファイル上に結合されます(結合は辞書順)。
{{- -}}
で両端空白除去すると関数が結合してしまうので、 {{- }}
で片側は空白を残しておきましょう。
{{- $alias := .Aliases.Table .Table.Name -}}
{{- $orig_tbl_name := .Table.Name -}}
func {{$alias.UpSingular}}Hello() {
fmt.Println("Hello")
}
Error: unable to generate output: failed to format template
1234 if err != nil {
1235 return false, errors.Wrap(err, "models: unable to check if category exists")
1236 }
1237
1238 return exists, nil
>>>> }func CategoryHello() {
1240 fmt.Println("Hello")
1241 }
また、import文はファイル共通で設定ファイルに指定したものが記述されるので、テンプレート側に書く必要はありません。
最終的にこのような内容になりました。
{{- $alias := .Aliases.Table .Table.Name}}
{{- $orig_tbl_name := .Table.Name}}
{{- $toSimSQL := dict "string" "simsql.Text" "null.String" "simsql.Text" "int" "simsql.Int64" "null.Int" "simsql.Int64" "bool" "simsql.Boolean" "null.Bool" "simsql.Boolean"}}
func CreateDummy{{$alias.UpSingular}}Table(db *memory.Database) *memory.Table {
tableName := "{{$alias.DownSingular}}"
table := memory.NewTable(tableName, simsql.NewPrimaryKeySchema(simsql.Schema{
{{- range $column := .Table.Columns}}
{Name: "{{$column.Name}}", Type: {{$column.Type | get $toSimSQL}}, Nullable: {{$column.Nullable}}, Source: tableName, PrimaryKey: {{$.Table.PKey.Columns | has $column.Name}}},
{{- end}}
}), db.GetForeignKeyCollection())
return table
}
// HACK: 余計なimport文が入ってしまうので消費(後述)
var _ = strconv.Itoa(42)
var _ null.String
カラムのTypeをgo-mysql-serverのtypeに変換する必要があるので、 dict
で変換mapを作成しています。
"github.com/volatiletech/null/v8"
と "strconv"
のimportはどうしても削除できなかったので、無駄な変数に代入してコンパイルエラーを防いでいます。
生成結果は以下のようになります。
package models
import (
"strconv"
"github.com/dolthub/go-mysql-server/memory"
simsql "github.com/dolthub/go-mysql-server/sql"
"github.com/volatiletech/null/v8"
)
func CreateDummyCategoryTable(db *memory.Database) *memory.Table {
tableName := "category"
table := memory.NewTable(tableName, simsql.NewPrimaryKeySchema(simsql.Schema{
{Name: "id", Type: simsql.Text, Nullable: false, Source: tableName, PrimaryKey: true},
{Name: "name", Type: simsql.Text, Nullable: false, Source: tableName, PrimaryKey: false},
}), db.GetForeignKeyCollection())
return table
}
var _ = strconv.Itoa(42)
var _ null.String
ハマったところ
import文を細かく指定できない
生成を2回に分けたのはこれが理由です。
imports
を設定すると、指定した生成ファイルの import
文を置き換えます。
[imports.all]
standard = []
third_party = [
'simsql "github.com/dolthub/go-mysql-server/sql"',
'"github.com/dolthub/go-mysql-server/memory"',
'"github.com/volatiletech/null/v8"',
]
しかし、テンプレートファイルを分けても生成時に同じファイルにマージされるため、デフォルトの処理用のimportが消えてしまいコンパイルエラーになってしまいます。
ファイル名指定も可能ですが singleton
(テーブル定義と独立した、モジュールで1度しか生成しない処理)にしか使えないため今回は利用できませんでした2。
unique制約が指定できない
go-mysql-serverでカラムにunique制約をかける方法が見つかりませんでした。現状、本来落ちるはずの条件が通ってしまう可能性があります。
Column以外の場所に記載する必要があるのでしょうか…(要検証)?
おわりに
以上、SQLBoilerのテンプレート機能でテストコードを(一部)自動生成する方法の紹介でした。いくつかハマった場所もありましたが、半日程度で実装することができました。
デフォルトの処理で痒い所に手が届かないときの選択肢として覚えておきたいと思います。