# PostgreSQL 的 deferred constraint ## 情境 1. 同一個 list 中相同 order 的 item 只能有一個 ([軟刪除](https://github.com/jhawthorn/discard)) 2. api 一次會傳送多筆資料同時更新所有資料的 order ### migration ```ruby class CreateLists < ActiveRecord::Migration[7.1] def change create_table :lists do |t| t.string :name t.datetime :discarded_at t.timestamps end end end ``` ```ruby class CreateItems < ActiveRecord::Migration[7.1] def change create_table :items do |t| t.references :list, null: false t.string :content t.integer :order, null: false t.datetime :discarded_at t.timestamps end add_index :items, %i[list_id order], unique: true, where: 'discarded_at is null' end end ``` ### model ```ruby class List < ApplicationRecord include Discard::Model default_scope { kept } has_many :items accepts_nested_attributes_for :items end ``` ```ruby class Item < ApplicationRecord include Discard::Model default_scope { kept } belongs_to :list validates :order, uniqueness: { scope: :list_id, conditions: -> { where(discarded_at: nil) } } end ``` ### Controller ```ruby class ListsController < ApplicationController before_action :set_list def update @list.update!(lists_params) render json: @list.as_json(include: :items) end private def lists_params params.require(:list).permit(:name, items_attributes: [:id, :content, :order]) end def set_list @list = List.find(params[:id]) end end ``` ### API PATCH `/list/1/items` body ```json { "list": { "name": "xxx", "items_attributes": [ { "id": 1, "content": "xxx", "order": 0 }, { "id": 2, "content": "xxx", "order": 1 } ] } } ``` ## 問題 當重新排序的時候會驗證出錯,假如 DB 資料 item id 1 的 order 是 0,item id 2 的 order 是 1,而 API 傳送交換順序的資料,更新資料會因為一筆一筆驗證而出錯 (改用 insert_all 也會出錯) ![截圖 2024-07-07 下午9.21.57](https://hackmd.io/_uploads/r1jo8fODR.png) ![截圖 2024-07-07 下午9.23.36](https://hackmd.io/_uploads/S1iJPfODR.png) ## DB 規則 | 名稱 | deferred | conditional (where) | | -------- | -------- | -------- | | unique ==index== | 不行 | 可以 (稱 [partial index](https://hackmd.io/@cindyliu923/BkF-pw8sT)) | | unique constraint | 可以 | 不行 | | exclusive constraint | 可以 | 可以 | SET CONSTRAINTS: Currently, only `UNIQUE`, `PRIMARY KEY`, `REFERENCES (foreign key)`, and `EXCLUDE` constraints are affected by this setting. NOT NULL and CHECK constraints are always checked immediately when a row is inserted or modified (not at the end of the statement). Uniqueness and exclusion constraints that have not been declared DEFERRABLE are also checked immediately. - 文件 - [Constraints (英)](https://www.postgresql.org/docs/current/ddl-constraints.html) - [Constraints (中)](https://docs.postgresql.tw/the-sql-language/ddl/constraints) - [SET CONSTRAINTS](https://www.postgresql.org/docs/current/sql-set-constraints.html) - [Deferrable SQL Constraints in Depth](https://begriffs.com/posts/2017-08-27-deferrable-sql-constraints.html) - 討論 - https://dba.stackexchange.com/questions/166082/deferrable-unique-index-in-postgres ## 解法 ### 新增 migration 並移除 model 上的驗證 ```ruby class UpdateItemsConstraint < ActiveRecord::Migration[7.1] def change remove_index :items, %i[list_id order], unique: true, where: 'discarded_at is null' add_exclusion_constraint :items, 'list_id WITH =, "order" WITH =', where: 'discarded_at is null', deferrable: :deferred end end ``` ![截圖 2024-07-07 下午10.28.42](https://hackmd.io/_uploads/SJX4LXdw0.png) - 其他相關資料 - https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/PostgreSQL/SchemaStatements.html - https://www.shakacode.com/blog/defer-unique-constraints-in-postgresql-with-rails/ ### 測試注意 測試時 [DatabaseCleaner](https://github.com/DatabaseCleaner/database_cleaner) 必須使用 `truncation` 的策略才能測試到因為 DB ==deferred== constraint 而 raise 的 error (ex: `PG::ExclusionViolation`) ```ruby # rails_helper.rb RSpec.configure do |config| config.before(:suite) do DatabaseCleaner.clean_with(:truncation) end config.before(:each) do DatabaseCleaner.strategy = :truncation DatabaseCleaner.start end config.after(:each) do DatabaseCleaner.clean end end ``` ![截圖 2024-07-07 下午10.31.15](https://hackmd.io/_uploads/rkg0LQuvA.png) `truncation` 和 `transaction` 是 DatabaseCleaner 的兩種清理策略: - `truncation` 原理:刪除表中的所有數據(用 TRUNCATE 命令)。 優點:徹底清空數據,確保測試環境乾淨。 缺點:速度較慢,特別是對於大數據庫。 - `transaction` 原理:在每個測試開始時啟動一個事務,測試結束後回滾事務。 優點:速度非常快,因為事務的開始和回滾比刪除數據快得多。 缺點:某些數據庫約束(如外鍵約束)在事務內可能不完全起作用。 總結 `truncation`:適合複雜關係和需要徹底清理的情況,但速度較慢。 `transaction`:適合大多數情況,速度快,但對某些約束支持有限。 可以在 example 加上參數調整要用哪個方式清理