以下の条件に対して、Activerecord-Importを利用したいシーンがあり、いろいろと勉強になったので記録です。
- Itemテーブルが持つcode, typeカラムには、複合ユニーク制約が設定されている
- 条件がdeleted_at IS NULLの部分インデックスが設定されている
- Itemのレコードを一括でインポートし、重複キーが存在する場合に指定されたカラムを更新したい(要はUPSERTしたい)
schema.rbcreate_table "items", force: :cascade do |t| t.string "name", null: false # ... # いろいろなカラム # ... t.string "code", null: false t.string "type", null: false t.datetime "deleted_at" t.index ["code", "type"], name: "index_items_on_code_and_type", unique: true, where: "(deleted_at IS NULL)" end
※ テーブル名・カラム名は実際の開発時のものとは異なります。
環境
Ruby 3.2.2 Rails 7.0.4.3 activerecord-import 1.5.1 PostgreSQL 11
結論
最終的には以下のようなコードになりました。
item.rbItem.import!(items, on_duplicate_key_update: { conflict_target: %i[code type], index_predicate: "deleted_at IS NULL", columns: [name, ... ], })
結論に至るまで
ON CONFLICT DO UPDATE
PostgreSQLでUPSERTをするためにはON CONFLICT DO UPDATEを使う必要があるようです。
ON CONFLICT DO UPDATE updates the existing row that conflicts with the row proposed for insertion as its alternative action. (https://www.postgresql.org/docs/current/sql-insert.html)
READMEを見ると、
(https://github.com/zdennis/activerecord-import?tab=readme-ov-file#duplicate-key-update)
とあるので、早速やってみました。
item.rbItem.import!(items, on_duplicate_key_update: { conflict_target: %i[code type], columns: [name, ... ], })
しかし、処理を実行すると、CONFLICTに指定しているカラムがユニークじゃないよ、とエラーになります。
ActiveRecord::StatementInvalid: PG::InvalidColumnReference: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
このとき発行されるSQLは以下です。
INSERT INTO "items" ("id","code","name", "type","created_at","updated_at", "deleted_at") VALUES (...) ON CONFLICT (code, type) DO UPDATE SET "name"=EXCLUDED."name","code"=EXCLUDED."code", ...
index_predicate
発行されたSQLにて部分インデックスを考慮できていないことが気になり、改めてドキュメントを見るとindex_predicateというオプションを見つけました。
index_predicate :Used to allow inference of partial unique indexes. (https://www.postgresql.org/docs/current/sql-insert.html)
activerecord-importで「index predicate」について検索すると、以下のIssueに紐づくPRで導入されていることがわかりました。
実際にオプションを指定してみると、期待通りの挙動になりました。
item.rb# (再掲) Item.import!(items, on_duplicate_key_update: { conflict_target: %i[code type], index_predicate: "deleted_at IS NULL", columns: [name, ... ], })
発行されるSQL
INSERT INTO "items" ("id","code","name", "type","created_at","updated_at", "deleted_at") VALUES (...) ON CONFLICT (code, type) WHERE deleted_at IS NULL # ちゃんと絞り込まれている DO UPDATE SET "name"=EXCLUDED."name","code"=EXCLUDED."code", ...
おわりに
index_predicateについては、READMEには記載がなく、コード内のドキュメントに記載がありました。 それについてIssueが上がっていたので、PRを出してみたところ無事マージされました(OSS初コントリビュート🎉)。
Postgresqlの仕様やOSSへのPRの出し方まで学べた、実りある開発となりました。