1. touyu

    Posted

    touyu
Changes in title
+SQLBoilerでBulk Insertを実現する方法
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,156 @@
+## 概要
+この記事を書いている段階では、SQLBoilerには、Bluk Insert は実装されていません。
+[issue](https://github.com/volatiletech/sqlboiler/issues/101)や[Pull Request](https://github.com/volatiletech/sqlboiler/pull/386)はいくつか上がっているようですが、内部に取り込まれる様子がありません。
+おそらくその理由は、MySQL、Postgresの両方にうまく対応しつつ、Insertした複数のstructにIDを埋める、という用件を満たせないからだと推測されます。
+(SQLBoilerでは、通常のInsertではInsert後に`last_insert_id` を用いて、structのIDにマッピングするように実装されています)
+Bulk Insert が実装されていないORMは他にもあり、それらもほぼ同様の理由で導入が見送られているのだと思います。
+
+しかし、実務では、Bulk Insert を使いたい場面は多々発生します。
+どうにかうまく、実現できる方法はないでしょうか?
+先ほど、Bluk Insertが実装されない理由を述べましたが、裏を返せば、そこを妥協すれば実装可能なわけです。
+つまり、実務レベルでは、「使っているDBに実装を合わせつつ、structのIDにマッピングされなくてもよい」という風に考えることもできます。
+SQLBoilerには、コード生成のテンプレートに使うファイルを変更する機能があります。その機能を使い、Bluk 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が心地の良いものになりました。