SmartHRではRuby on Railsを多くのサービスで採用しています。
そのため、不足している機能や不具合があればrails/railsへコントリビュートすることがあります。
今日は、日々のぽつぽつとしたコントリビュートの中から、Rails 7.1に追加したUNIQUE制約について紹介します。
unique_constraint(UNIQUE制約)
UNIQUE制約(unique_constraint)はRails7.1(執筆時は未リリース)から利用可能になるActiveRecordの新機能です。 rails/rails#46192
PostgreSQLでしか利用できませんが、下記のようにunique_constraint
で遅延可能なUNIQUE制約を定義できるようになりました。
# create_table内で使う場合 create_table :items do |t| t.integer :position, null: false t.unique_constraint [:position], deferrable: :immediate end # create_table外で使う場合 add_unique_constraint :items, [:position], deferrable: :immediate # 既存のINDEXを遅延可能なUNIQUE制約に変更する場合 add_unique_constraint :items, deferrable: :immediate, using_index: "index_items_on_position"
既存のUNIQUE INDEXの挙動
UNIQUE INDEXが貼ってあるカラムを扱うときに、レコードをまとめて更新しようとして制約に引っかかることがあります。
例として、以下のサンプルコードでUNIQUE INDEXがあるpositionカラムを入れ替える #swap_position
の実装について考えてみます。
ActiveRecord::Schema.define do create_table :items, force: true do |t| t.integer :position, null: false t.index [:position], unique: true, name: "index_items_on_position" end end class Item < ActiveRecord::Base # @param other [Item] def swap_position(other) transaction do tmp = position update!(position: other.position) other.update!(position: tmp) end end end item_1 = Item.create!(position: 0) item_2 = Item.create!(position: 1) item_1.swap_position(item_2)
UNIQUE INDEXは更新時に即時チェックされるため、上記の実装では update!(position: other.position)
を実行した時にpositionが重複してしまって、UNIQUE制約違反の例外が発生してしまいます。
# ERROR: 重複したキ ー値は一意性制約"index_items_on_position"違反となります (PG::UniqueViolation) # DETAIL: キー ("position")=(1) はすでに存在します。
困りましたね。
そこで、新たに追加された unique_constraint
の出番です。
遅延可能なUNIQUE制約をつくる
新たに追加された unique_constraint
を使えば、UNIQUE制約を遅延可能として定義できます。
さっそく、先ほどのUNIQUE INDEXを遅延可能なUNIQUE制約に置き換えてみましょう。
# using_indexには、既存のINDEX名を指定します。 add_unique_constraint :items, deferrable: :deferred, using_index: "index_items_on_position"
これで、先ほどの処理を実行しても例外は発生しなくなりました。
遅延可能なUNIQUE制約とはなにか
PostgreSQLにおけるUNIQUE制約(or UNIQUE INDEX)は、レコードに変更があれば即時チェックされます。
しかし、UNIQUE制約を遅延可能として定義すれば、チェックのタイミングをtransactionの終了時まで遅延させることができるようになります。
先ほどの例では、UNIQUE制約のチェックを遅延させて、チェックのタイミングを即時ではなく、2つの Item#position
を更新した後に遅延させたため、制約違反のエラーが発生しなくなりました。
オプションの値について
add_unique_constraint
に渡す deferrable:
オプションは false
か :immediate
か :deferrable
の値を渡すことができます。
それぞれ、次のような挙動をします。
deferrable: false
- デフォルト値
UNIQUE INDEX
と等価なので(※1)即時チェックされます。- これを指定するならこの機能を使う意味がないので、
add_index(..., unique: true)
を使えば良いと思います
deferrable: :immediate
- 遅延可能となる
- 初期値では即時チェック
SET CONSTRAINTS { ALL | name [, ...] } DEFERRED
をtransaction内で実行することで、transaction終了時まで制約のチェックを遅延できる
deferrable: :deferred
- 遅延可能となる
- 初期値では遅延チェック
SET CONSTRAINTS { ALL | name [, ...] } IMMEDIATE
をtransaction内で実行することで、制約を即時にチェックできる
UNIQUE INDEX vs UNIQUE制約
言葉が似ていますが、UNIQUE INDEXとUNIQUE制約はSQL上は別のものです。
内部的には両方ともUNIQUE INDEXを使用していますが、遅延可能なUNIQUE INDEXはUNIQUE制約と紐づけないと作れない縛りがあるので、今回のadd_unique_constraint
の機能を追加しました。
既存のUNIQUE INDEXをUNIQUE制約に置き換える上で、いくつか気をつけることがあるので列挙します。
- 内部的にはどちらもUNIQUE INDEXを持つので、検索時のパフォーマンスに影響はない
add_unique_constraint(..., using_index: "unique_index_name")
を使ってUNIQUE制約をつくる際に、短いがテーブルロックが発生する(※2)ON CONFLICT
句と遅延可能なUNIQUE制約は併用できない(※3)deferrable: :immediate
はSET CONSTRAINTS ... DEFERRED
をするまで遅延されないので、UNIQUE INDEXと挙動が同じような気がするが、実際はまとめて更新するSQLで挙動が異なるItem.update_all("position = position + 1")
のように1クエリでまとめて更新するとき- UNIQUE INDEXは、1レコードの更新ごとに制約をチェックする(EACH ROW)
- 遅延可能なUNIQUE制約は、すべて更新してから制約をチェックする(EACH STATEMENT)
- まとめて更新するときにUNIQUE INDEXが即時チェックするのは、違反があった時に早めに中断することでパフォーマンスを最適化するのが目的。なので、遅延可能なUNIQUE制約の方がパフォーマンスが悪くなる。(※4)
おわりに
add_unique_constraint
が追加されたことで、開発中のサービスではいくつかのコードをリファクタリングできそうです。次のRails7.1のリリースが待ち遠しいですね。
PRをレビューしてくださったyahondaさん、アドバイスをいただいたkamipoさん、大変お世話になりました。
特にyahondaさんは、PRのレビューにかなりの時間を割いていただきました。
このPRを出した後で、PostgreSQLの勉強会にも参加して調査してくださっているのをTwitterで観測して、「そこまでしてくださるCommiterいる...?」と感動しました。
レビューの指摘のおかげ様で、見落としていた挙動に気づけたり、PostgreSQLのコードに少しだけ触れることができました。心からとても感謝しています。
注釈
※1遅延しないUNIQUE CONSTRAINTは、クエリ実行前にCREATE INDEXに置き換えて解釈されるようです parse_utilcmd.c
※2コード上に注意書きがあった index.c
※3 UNIQUE制約/EXCLUSION制約ともに遅延可能な制約 + ON CONFLICTはエラーとなる execIndexing.c
※4 https://postgrespro.com/docs/postgresql/12/sql-createtable#id-1.9.3.85.9.4