Rails6 のちょい足しな新機能を試す85（insert_all upsert_all編）

# はじめに

Rails 6 に追加された新機能を試す第85段。 今回は、 `insert_all upsert_all` 編です。
Rails 6 では、データの一括登録、一括更新用メソッド `insert_all`, `insert_all!`, `upsert_all` の3つのメソッドが追加されました。

Ruby 2.6.4, Rails 6.0.0 で確認しました。
また、データベースに PostgreSQL 10.7 を使っています。

```shell
$ rails --version
Rails 6.0.0
```

今回は、 `title`, `isbn` の2つの属性を持つBookモデルを使ってデータを一括登録、一括更新を行うことで機能を試してみます。

# プロジェクトを作る

```shell
rails new rails_sandbox
cd rails_sandbox
```

# Book モデルを作る

`title`, `isbn` の2つの属性を持つBook モデルを作ります。

```shell
bin/rails g model Book title isbn
```

# `isbn` にユニークインデックスをつける

`isbn` にユニークインデックスをつけます。

データベースに登録されているデータと同じユニークキーを持つデータを一括更新、一括登録の中で処理した場合の動作を確認するためです。

```ruby:db/migrate/20190908115949_create_books.rb
class CreateBooks < ActiveRecord::Migration[6.0]
  def change
    ...
    add_index :books, :isbn, unique: true
  end
end
```

# seed データを作る

あらかじめデータを登録しておくため、seed データを作ります。

```ruby:db/seeds.rb
Book.create(
  [
    { title: 'Agile Web Development with Rails', isbn: '978-1680502510' },
    { title: 'Programming Ruby', isbn: '978-1937785499' }
  ]
)
```

# マイグレーションを実行し、seedデータを登録する

```shell
$ bin/rails db:create db:migrate db:seed
```

# insert_all

`insert_all` を試すためにスクリプトを作成します。

登録する2件のデータのうち2件目の isbn は、 既にデータベースに存在しています。
`unique_by` で `isbn` を指定しておきます。
また、メソッドの実行結果として、登録されたデータの `id` と `title` を返すようにします。

(`returning` は、MySQL では使えません。)

```ruby:scripts/insert_all.rb
now = Time.zone.now

inserted_books = Book.insert_all(
  [
    { title: 'Pragmatic Programmer', isbn: '978-0201616224', created_at: now, updated_at: now },
    { title: 'Agile Web Development with Rails 5.1', isbn: '978-1680502510', created_at: now, updated_at: now }
  ],
  unique_by: :isbn,
  returning: %i[id title]
)

pp inserted_books
```

実際に `rails console` から試してみます。`rails console` 終了時にデータベースのロールバックを実行して登録、更新をなかったことにするために、 `-s` オプションを使います。


```irb
$ bin/rails c -s
...
irb(main):001:0> load 'scripts/insert_all.rb'
   (0.3ms)  BEGIN
  Book Bulk Insert (0.4ms)  INSERT INTO "books"("title","isbn","created_at","updated_at") VALUES ('Pragmatic Programmer', '978-0201616224', '2019-09-14 22:37:12.006051', '2019-09-14 22:37:12.006051'), ('Agile Web Development with Rails 5.1', '978-1680502510', '2019-09-14 22:37:12.006051', '2019-09-14 22:37:12.006051') ON CONFLICT ("isbn") DO NOTHING RETURNING "id","title"
#<ActiveRecord::Result:0x000055de4b559ab8
 @column_types=
  {"id"=>
    #<ActiveModel::Type::Integer:0x000055de4a36ce90
     @limit=8,
     @precision=nil,
     @range=-9223372036854775808...9223372036854775808,
     @scale=nil>,
   "title"=>
    #<ActiveModel::Type::String:0x000055de4b54ca98
     @limit=nil,
     @precision=nil,
     @scale=nil>},
 @columns=["id", "title"],
 @hash_rows=nil,
 @rows=[[3, "Pragmatic Programmer"]]>
=> true
```

SQL文の最後が  `ON CONFLICT ("isbn") DO NOTHING RETURNING "id","title"` となっていることに注意してください。
実行結果から、 Pragmatic Programmer だけが登録されて Agile Web Development with Rails 5.1 は登録されなかったことがわかります。

実際に確認してみます。

```irb
irb(main):006:0> Book.all.map{|book| [book.id, book.title, book.isbn]}
  Book Load (0.6ms)  SELECT "books".* FROM "books"
=> [[1, "Agile Web Development with Rails", "978-1680502510"], [2, "Programming Ruby", "978-1937785499"], [3, "Pragmatic Programmer", "978-0201616224"]]
```

# `insert_all!`

今度は、 `insert_all!` を試します。
`insert_all!` では、 `unique_by` オプションが使えないことに注意してください。

```ruby:scripts/insert_all_exclamation.rb
now = Time.zone.now

inserted_books = Book.insert_all!(
  [
    { title: 'Pragmatic Programmer', isbn: '978-0201616224', created_at: now, updated_at: now },
    { title: 'Agile Web Development with Rails 5.1', isbn: '978-1680502510', created_at: now, updated_at: now }
  ],
  returning: %i[id title]
)

pp inserted_books
```

では、 `rails console` で試してみます。 一旦、 exit で抜けて、再度 `rails console` を実行して試します。

```irb
bin/rails c
irb(main):001:0> load 'scripts/insert_all_exclamation.rb'
  Book Bulk Insert (0.8ms)  INSERT INTO "books"("title","isbn","created_at","updated_at") VALUES ('Pragmatic Programmer', '978-0201616224', '2019-09-14 22:53:19.823710', '2019-09-14 22:53:19.823710'), ('Agile Web Development with Rails 5.1', '978-1680502510', '2019-09-14 22:53:19.823710', '2019-09-14 22:53:19.823710') RETURNING "id","title"
Traceback (most recent call last):
        1: from (irb):1
ActiveRecord::RecordNotUnique (PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_books_on_isbn")
DETAIL:  Key (isbn)=(978-1680502510) already exists.
```

今度は、 `ActiveRecord::RecordNotUnique` 例外が発生しました。
データが登録されなかったことも確認します。

```irb
irb(main):002:0> Book.all.map{|book| [book.id, book.title, book.isbn]}
  Book Load (0.4ms)  SELECT "books".* FROM "books"
=> [[1, "Agile Web Development with Rails", "978-1680502510"], [2, "Programming Ruby", "978-1937785499"]]
```

# upsert_all

今度は、 `upsert_all` を試します。

```ruby:scripts/upsert_all.rb
now = Time.zone.now

inserted_books = Book.upsert_all(
  [
    { title: 'Pragmatic Programmer', isbn: '978-0201616224', created_at: now, updated_at: now },
    { title: 'Agile Web Development with Rails 5.1', isbn: '978-1680502510', created_at: now, updated_at: now }
  ],
  unique_by: :isbn,
  returning: %i[id title]
)

pp inserted_books
```

では、実行してみましょう。

```irb
$ bin/rails c -s
...
irb(main):001:0> load 'scripts/upsert_all.rb'
   (0.2ms)  BEGIN
  Book Bulk Upsert (0.5ms)  INSERT INTO "books"("title","isbn","created_at","updated_at") VALUES ('Pragmatic Programmer', '978-0201616224', '2019-09-14 23:02:19.763608', '2019-09-14 23:02:19.763608'), ('Agile Web Development with Rails 5.1', '978-1680502510', '2019-09-14 23:02:19.763608', '2019-09-14 23:02:19.763608') ON CONFLICT ("isbn") DO UPDATE SET "title"=excluded."title","created_at"=excluded."created_at","updated_at"=excluded."updated_at" RETURNING "id","title"
#<ActiveRecord::Result:0x000055de490db808
 @column_types=
  {"id"=>
    #<ActiveModel::Type::Integer:0x000055de4b2e9328
     @limit=8,
     @precision=nil,
     @range=-9223372036854775808...9223372036854775808,
     @scale=nil>,
   "title"=>
    #<ActiveModel::Type::String:0x000055de490621b0
     @limit=nil,
     @precision=nil,
     @scale=nil>},
 @columns=["id", "title"],
 @hash_rows=nil,
 @rows=
  [[9, "Pragmatic Programmer"], [1, "Agile Web Development with Rails 5.1"]]>
=> true
```

SQL文の最後が、 `ON CONFLICT ("isbn") DO UPDATE SET ...` となっていることに注意してください。

結果から、 `insert_all` と違い2件とも処理されていることがわかります。
実際に確認します。

```irb
irb(main):003:0> Book.all.map{|book| [book.id, book.title, book.isbn]}
  Book Load (0.4ms)  SELECT "books".* FROM "books"
=> [[2, "Programming Ruby", "978-1937785499"], [9, "Pragmatic Programmer", "978-0201616224"], [1, "Agile Web Development with Rails 5.1", "978-1680502510"]]
```

Pragmatic Programmer が増え、 Agile Web Development with Rails 5.1 に変更されていることがわかります。

# 結果を整理すると

- `insert_all` は同じユニークキーのデータは無視（登録しない）して、それ以外のデータを登録する。
- `insert_all!` は同じユニークキーのデータがあると `ActiveRecord::RecordNotUnique` 例外を raise し、ロールバックする。
- `upsert_all` は同じユニークキーのデータがあると更新して、データが無ければ登録する。


となります。

なお、これらのメソッドは、 Model を介さず、直接、SQLを組み立てて実行するため、 validation によるチェックや Model のコールバックは実行されません。

# 試したソース

試したソースは以下にあります。
https://github.com/suketa/rails_sandbox/tree/try085_insert_all

# 参考情報

* [What is new in Rails 6.0](https://bogdanvlviv.com/posts/ruby/rails/what-is-new-in-rails-6_0.html)
* [Add insert_many to ActiveRecord models](https://github.com/rails/rails/pull/35077)
* [Bulk Insert: Reuse indexes for unique_by](https://github.com/rails/rails/pull/35546)