LoginSignup
15
13

More than 3 years have passed since last update.

SQLBoilerでBulk Insertを実現する方法

Last updated at Posted at 2019-12-07

概要

この記事を書いている(2019/12/07)時点では、SQLBoilerに、Bulk Insert は実装されていません。
issuePull Requestはいくつか上がっているようですが、内部に取り込まれる様子がありません。
おそらくその理由は、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関数生成され、以下のサンプルのように使えるようになっているはずです。

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

最後に

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

15
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
13