Activerecord-ImportでUPSERT(複合ユニーク × 部分インデックス)
2024-03-16

以下の条件に対して、Activerecord-Importを利用したいシーンがあり、いろいろと勉強になったので記録です。

  • Itemテーブルが持つcode, typeカラムには、複合ユニーク制約が設定されている
  • 条件がdeleted_at IS NULLの部分インデックスが設定されている
  • Itemのレコードを一括でインポートし、重複キーが存在する場合に指定されたカラムを更新したい(要はUPSERTしたい)
schema.rb
create_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.rb
Item.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を見ると、

screen.pnghttps://github.com/zdennis/activerecord-import?tab=readme-ov-file#duplicate-key-update

とあるので、早速やってみました。

item.rb
Item.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で導入されていることがわかりました。

screen.png

実際にオプションを指定してみると、期待通りの挙動になりました。

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初コントリビュート🎉)。

screen(12).png

Postgresqlの仕様やOSSへのPRの出し方まで学べた、実りある開発となりました。

参考にさせていただいた記事

© 2023 yutasb