---
# System prepended metadata

title: PostgreSQL 的 deferred constraint

---

# 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 加上參數調整要用哪個方式清理