Help us understand the problem. What is going on with this article?

SQLBoilerでBulk Insertを実現する方法

概要

この記事を書いている(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が心地の良いものになりました。

touyu
RINACITA Inc, CTO Software Engineer(Swfit, Go)
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした