2019-12-02

Rails6で導入されたinsert_all、insert_all!、upsert_allを使ってみる

Rails6にて insert_all, insert_all!, upsert_all という一括作成・更新の機能が導入された。
https://railsguides.jp/6_0_release_notes.html#active-record

これらのメソッドを、公式のAPIドキュメントを読みつつ手元の環境で実際に使ってみた記録を残す。

環境

  • ruby 2.6.5p114
  • rails 6.0.1
  • PostgreSQL 10.11

検証用データベースとして、次のスキーマのusersテーブルがあるものとする

Column Type Nullable Default
id integer not null nextval('users_id_seq'::regclass)
name character varying not null
hobby character varying
created_at timestamp without time zone not null
updated_at timestamp without time zone not null

insert_all, insert_all!

まずかんたんな例として、3件のuserデータの一括作成を試みる。
APIドキュメント によると、引数はHashの配列を渡す必要があるとのこと。name キーだけ含んだHashの配列を与えて実行してみる。

users = [
  { name: 'Alice' },
  { name: 'Bob' },
  { name: 'Charles' },
]
User.insert_all(users)
# => ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR:  null value in column "created_at" violates not-null constraint

created_at のNOT NULL制約に引っかかって、ActiveRecord::NotNullViolation 例外が発生してしまった。 NOT NULL制約がついているカラムは明示的に指定しなければならないようだ。

users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', created_at: Time.current, updated_at: Time.current },
  { name: 'Charles', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)
# => #<ActiveRecord::Result:0x00007f92fc3c3728 @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007f92fb5ca180 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>}, @columns=["id"], @hash_rows=nil, @rows=[[2], [3], [4]]>

User.count
# => 3

今度は成功する。発行されるSQLは以下の通り、1SQLだ。

INSERT INTO "users"(
  "name", "created_at", "updated_at"
)
VALUES
  ('Alice', '2019-12-01 14:06:12.151661', '2019-12-01 14:06:12.151710'),
  ('Bob', '2019-12-01 14:06:12.151717', '2019-12-01 14:06:12.151721'),
  ('Charles', '2019-12-01 14:06:12.151724', '2019-12-01 14:06:12.151745')
ON CONFLICT DO NOTHING RETURNING "id"

返り値として ActiveRecord::Result のインスタンスが返ってきており、#to_aで作成した行を参照できるが、ここにはid情報しか含まれていない。

res.to_a
# => [{"id"=>2}, {"id"=>3}, {"id"=>4}]

作成処理を実行したあとにその行情報を参照して何かをしたいことよくあるので、このままだとやや使い勝手が悪いだろう。
そういったときは、#insert_allreturing オプションを使うことで返り値の情報を変更できる。 しかし、APIドキュメントによると returning オプションはPostgreSQL限定のようなので注意されたし。

users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', created_at: Time.current, updated_at: Time.current },
  { name: 'Charles', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users, returning: %i[id name hobby])

res.to_a
# => [
#   {"id"=>8, "name"=>"Alice", "hobby"=>nil},
#   {"id"=>9, "name"=>"Bob", "hobby"=>nil},
#   {"id"=>10, "name"=>"Charles", "hobby"=>nil}
# ]

次に、unique制約に引っかかるデータを同時に作成しようとしたときにどうなるかを見てみる。usersテーブルのnameカラムにはunique制約が付いているので、nameの値が同じレコードを同時に作成してみる。

# 2レコードともnameをAliceとする
users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)

User.count
# => 1

User.count の結果を見るとわかるように、insert_all の場合は重複した行は無視する。もし例外を起こしたい場合はbangつきの insert_all! を使えば良い。

# 2レコードともnameをAliceとする
users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)
# => ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_name"

User.count
# => 0

ActiveRecord::RecordNotUnique 例外が発生し、userデータは1件も作成されない。

upsert_all

UPSERTの検証をしたいので、name="Alice"のレコードを事前に作っておく。

User.create!(name: 'Alice')

Aliceのhobbyを更新しつつ、name="Bob"のレコードをINSERTしてみよう。

users = [
  { name: 'Alice', hobby: 'cooking' },
  { name: 'Bob', created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users)
# => ArgumentError: All objects being inserted must have the same keys

おっと、ArgumentError が発生してしまった。どうやら配列内のHashの構造はすべて一致していなければならないようだ。

users = [
  { name: 'Alice', hobby: 'cooking', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', hobby: nil, created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users)
# => ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_name"

今度は ActiveRecord::RecordNotUnique が発生してしまった。どうやらname="Alice"のHashが新規作成分として見なされてしまったようだ。
どのキーをUPSERTの判断軸とするかを unique_by オプションで明示的に指定するとこのエラーを回避できる。

users = [
  { name: 'Alice', hobby: 'cooking', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', hobby: nil, created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users, unique_by: 'name')
# => #<ActiveRecord::Result:0x00007f9301a1a838 @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007f92fb5ca180 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>}, @columns=["id"], @hash_rows=nil, @rows=[[27], [29]]>

User.count
# => 2

しかし、unique_by オプションはPostgreSQLかSQLiteでしか使えないようなので、これもまた注意されたし..。MySQLだとこの状況、どうすればいいんだろう。

その他のメモ

その他ドキュメントを読んでいてのメモ

  • insert_allupsert_all はモデルのインスタンスを作らないので、CallbackやValidationはスキップされる
  • bangつきの upsert_all! は存在しない

参考URL