SQLBoilerでBulk Insertを実現する方法

## 概要
この記事を書いている（2019/12/07）時点では、SQLBoilerに、Bulk Insert は実装されていません。
[issue](https://github.com/volatiletech/sqlboiler/issues/101)や[Pull Request](https://github.com/volatiletech/sqlboiler/pull/386)はいくつか上がっているようですが、内部に取り込まれる様子がありません。
おそらくその理由は、MySQL、Postgresなどの各種DBにうまく対応しつつ、Insertした複数のstructにIDを埋める、という用件を満たせないからだと推測されます。
（SQLBoilerでは、通常のInsertではInsert後に`last_insert_id` を用いて、structのIDにマッピングするように実装されています）
Bulk Insert が実装されていないORMは他にもあり、それらもほぼ同様の理由で導入が見送られていそうな気がします。

しかし、実務では、Bulk Insert を使いたい場面は多々発生します。
どうにかうまく、実現できる方法はないでしょうか？
先ほど、Bulk Insertが実装されない理由を述べましたが、裏を返せば、そこを妥協すれば実装可能なわけです。
つまり、実務レベルでは、「使っているDBに実装を合わせつつ、structのIDにマッピングされなくてもよい」という風に割り切ることもできます。
SQLBoilerには、コード生成のテンプレートに使うファイルを変更する機能があります。その機能を使い、Bulk Insert用のテンプレートファイルを作ることでこの問題を解決することができます。

今回は、MySQLを使用することを前提とした場合で進めていきます。

## SQLBoilerのテンプレート変更

### テンプレートファイルの作成

まず、Bulk Insert用の関数の元になるテンプレートファイルを作成します。
作業ディレクトリ配下に、`templates` ディレクトリを作成し、その中に、`15_bulk_insert.go.tql` という名前でテンプレートを保存します。
（この時のディレクトリ名とファイル名は適宜変更可能ですが、コード生成される順番に影響します）

今回は、`UpdateAll`, `DeleteAll` などの名前に合わせて、`InsertAll` という関数名にします。

```15_bulk_insert.go.tql
{{- $alias := .Aliases.Table .Table.Name -}}
{{- $schemaTable := .Table.Name | .SchemaTable}}

// InsertAll inserts all rows with the specified column values, using an executor.
func (o {{$alias.UpSingular}}Slice) InsertAll({{if .NoContext}}exec boil.Executor{{else}}ctx context.Context, exec boil.ContextExecutor{{end}}, columns boil.Columns) error {
	ln := int64(len(o))
	if ln == 0 {
		return nil
	}
 	var sql string
	vals := []interface{}{}
	for i, row := range o {
        {{- template "timestamp_bulk_insert_helper" . }}

        {{if not .NoHooks -}}
		if err := row.doBeforeInsertHooks(ctx, exec); err != nil {
			return err
		}
		{{- end}}

 		nzDefaults := queries.NonZeroDefaultSet({{$alias.DownSingular}}ColumnsWithDefault, row)
 		wl, _ := columns.InsertColumnSet(
			{{$alias.DownSingular}}AllColumns,
			{{$alias.DownSingular}}ColumnsWithDefault,
			{{$alias.DownSingular}}ColumnsWithoutDefault,
			nzDefaults,
		)
 		if i == 0 {
			sql = "INSERT INTO {{$schemaTable}} " + "({{.LQ}}" + strings.Join(wl, "{{.RQ}},{{.LQ}}") + "{{.RQ}})" + " VALUES "
		}
 		sql += strmangle.Placeholders(dialect.UseIndexPlaceholders, len(wl), len(vals)+1, len(wl))
		if i != len(o)-1 {
			sql += ","
		}
 		valMapping, err := queries.BindMapping({{$alias.DownSingular}}Type, {{$alias.DownSingular}}Mapping, wl)
		if err != nil {
			return err
		}
		value := reflect.Indirect(reflect.ValueOf(row))
		vals = append(vals, queries.ValuesFromMapping(value, valMapping)...)
	}
 	if boil.DebugMode {
		fmt.Fprintln(boil.DebugWriter, sql)
		fmt.Fprintln(boil.DebugWriter, vals...)
	}

	{{if .NoContext -}}
	_, err := exec.Exec(ctx, sql, vals...)
	{{else -}}
	_, err := exec.ExecContext(ctx, sql, vals...)
	{{end -}}

	if err != nil {
		return errors.Wrap(err, "{{.PkgName}}: unable to insert into {{.Table.Name}}")
	}

	return nil
}

{{- define "timestamp_bulk_insert_helper" -}}
	{{- if not .NoAutoTimestamps -}}
	{{- $colNames := .Table.Columns | columnNames -}}
	{{if containsAny $colNames "created_at" "updated_at"}}
		{{if not .NoContext -}}
	if !boil.TimestampsAreSkipped(ctx) {
		{{end -}}
		currTime := time.Now().In(boil.GetLocation())
		{{range $ind, $col := .Table.Columns}}
			{{- if eq $col.Name "created_at" -}}
				{{- if eq $col.Type "time.Time" }}
		if row.CreatedAt.IsZero() {
			row.CreatedAt = currTime
		}
				{{- else}}
		if queries.MustTime(row.CreatedAt).IsZero() {
			queries.SetScanner(&row.CreatedAt, currTime)
		}
				{{- end -}}
			{{- end -}}
			{{- if eq $col.Name "updated_at" -}}
				{{- if eq $col.Type "time.Time"}}
		if row.UpdatedAt.IsZero() {
			row.UpdatedAt = currTime
		}
				{{- else}}
		if queries.MustTime(row.UpdatedAt).IsZero() {
			queries.SetScanner(&row.UpdatedAt, currTime)
		}
				{{- end -}}
			{{- end -}}
		{{end}}
		{{if not .NoContext -}}
	}
		{{end -}}
	{{end}}
	{{- end}}
{{- end -}}

```

### sqlboiler.tomlの変更
sqlboiler.tomlに以下を追加します。
本来のテンプレートもそのまま使う場合には、sqlboiler内部の、`templates`, `templates_test` を含める必要があります。

```sqlboiler.toml
templates = [
    "/path/to/sqlboiler/templates",
    "/path/to/sqlboiler/templates_test",
    "templates"
]
```

## コード生成
通常通り、以下のコマンドでコードを生成します。

```
$ sqlboiler --wipe mysql
```

すると、新しく、各モデルに`InsertAll`関数生成され、以下のサンプルのように使えるようになっているはずです。

```go
slice := models.UserSlice{}
err := slice.InsertAll(...)
```

## 最後に
SQLBoilerは、今まで使用したGoのORMの中で最も手になじんだものでした。
RelationもGormなどに比べて直感的に書け、唯一、残念だったのがBulk Insertが無いことでした。
そのデメリットもほぼなくなったことで、個人的にさらにSQLBoilerが心地の良いものになりました。