SQLBoilerでテスト用の自作テンプレートを生成する

[Go Advent Calendar](https://qiita.com/advent-calendar/2022/go)の枠が空いていたので参加させていただきました。

# TL; DR

[SQLBoiler](https://github.com/volatiletech/sqlboiler) のテストコードに使用するために [go-mysql-server](https://github.com/dolthub/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のテンプレート機能](https://github.com/volatiletech/sqlboiler#templates)を使って自動生成してみた方法を紹介します。

# 背景

本題の前に、今回使用しているSQLBoilerとgo-mysql-serverについて軽く紹介します。（すでにご存じの方は読み飛ばしてください）

## SQLBoiler

SQLBoilerはSQLのORMです。

https://github.com/volatiletech/sqlboiler

GoのORMとしては[GORM](https://gorm.io/ja_JP/docs/index.html) も有名ですが、こちらは稼働しているDBのスキーマを読み込みコードを自動生成するという特徴を持ちます。
テーブルや列の型に合わせた構造体が生成されるので、型安全にDB操作を実装できます。

さらに、テンプレートを記述することで自動生成ファイルを追加することが可能です。本記事ではこの機能を使って、後述のgo-mysql-serverのスキーマ定義を自動生成します。

## go-mysql-server

go-mysql-serverは、インメモリでMySQL互換のDBとして動作するテスト用DBライブラリです。ライブラリ経由でテーブルやレコード操作ができるため、「取得テスト実行前にレコードを挿入」といった使い方が可能です。

https://github.com/dolthub/go-mysql-server

```go
// INSERT INTO user (id, name age) values ('0123456789ABCDEFGHJKMNPQRS', 'Mike', 20);
table.Insert(ctx, sql.NewRow(
	"0123456789ABCDEFGHJKMNPQRS",
	"Mike",
	int64(20),
))
```

テストでの使い方について詳細は過去の記事をご覧ください。

https://qiita.com/Syuparn/items/7fabaeadbfcd2532fd62#4-sql%E3%81%AEsimulator%E3%82%92%E4%BD%BF%E7%94%A8

一方で、準備としてライブラリ上でテーブル構造体を逐一定義する必要があります。
「スキーマ定義と2重管理になってるし、こっちを自動生成できないかな...?」というのが本記事の動機です。

```go
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](https://pkg.go.dev/text/template))を使ってコード生成を拡張することが可能です。
デフォルトで生成されるコードもGo Templateを使用しているので、これらをお手本に書くのが良いと思います。

https://github.com/volatiletech/sqlboiler/tree/master/templates

## 設定

まずは自作テンプレートを読み込むため、 `sqlboiler.toml` に設定を加えます。

注意点として、デフォルトの内容を生成するにはデフォルトのテンプレートと自作テンプレートの両方を指定する必要があります。

詳しくはこちらの記事が参考になります。

https://zenn.dev/gami/articles/0fb2cf8b36aa09#%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88

後述のimport文の問題もあり、今回は設定ファイル自体を2つに分割し、別々にコード生成を行うことにしました。

```go:generate.go
package main

// デフォルトのテンプレートを models/ へ生成
//go:generate sqlboiler mysql
// 自作テンプレートを simulator_models/ へ生成
//go:generate sqlboiler mysql -c sqlboiler.simulator.toml
```

```toml: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](http://masterminds.github.io/sprig/) の関数が使用可能です[^sprig]。

生成時に使用できるオブジェクトは以下の通りです。

- `Alias`: テーブル名を `camelCase`, `PasCalCase` 等に変換してくれる ([実装](https://github.com/volatiletech/sqlboiler/blob/c6e262b8478df41a168168eab221d3a477b1607c/boilingcore/aliases.go#L13))
- `Table`: テーブル定義 ([実装](https://github.com/volatiletech/sqlboiler/blob/43dcd896e18b1ca77570d2366901a444ce6b12f5/drivers/table.go#L8))
- `Column`: カラム定義 ([実装](https://github.com/volatiletech/sqlboiler/blob/43dcd896e18b1ca77570d2366901a444ce6b12f5/drivers/column.go#L13))

注意点として、テンプレートファイルを分けても、テンプレートに実装した内容は特定のファイル上に結合されます（結合は辞書順）。
`{{- -}}` で両端空白除去すると関数が結合してしまうので、 `{{- }}` で片側は空白を残しておきましょう。

```:うまくいかない例
{{- $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文はファイル共通で設定ファイルに指定したものが記述されるので、テンプレート側に書く必要はありません。

最終的にこのような内容になりました。

```:templates/99_go_mysql_server_table.go.tpl
{{- $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`](http://masterminds.github.io/sprig/dicts.html) で変換mapを作成しています。

`"github.com/volatiletech/null/v8"` と `"strconv"`のimportはどうしても削除できなかったので、無駄な変数に代入してコンパイルエラーを防いでいます。

生成結果は以下のようになります。

```go:category.go
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` 文を置き換えます。

```toml:sqlboiler.simlator.toml
[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度しか生成しない処理)にしか使えないため今回は利用できませんでした[^import]。

## unique制約が指定できない
go-mysql-serverでカラムにunique制約をかける方法が見つかりませんでした。現状、本来落ちるはずの条件が通ってしまう可能性があります。
Column以外の場所に記載する必要があるのでしょうか…（要検証）？

# おわりに

以上、SQLBoilerのテンプレート機能でテストコードを（一部）自動生成する方法の紹介でした。いくつかハマった場所もありましたが、半日程度で実装することができました。
デフォルトの処理で痒い所に手が届かないときの選択肢として覚えておきたいと思います。

[^sprig]: Helmでもkubectl等でも使えます。ありがたや...
[^import]: そもそも公式に「基本的に避けるべき」という[注意書き](https://github.com/volatiletech/sqlboiler#imports)があるので自業自得ですね...