在 PostgreSQL 15 或更新版本中,使用宣告為 DEFERRABLE 的 UNIQUE NULLS NOT DISTINCT 約束,然後在重新排序交易中延後檢查。這樣文章資料列就能交換位置,同時確保最終順序唯一,包括父項為 NULL 的資料列。PostgreSQL 約束語法
本教學適用於在 Mac Terminal 工作、需要重新排列單一父項群組內合成文章資料列的開發者。內容包含 SQL 示範、可驗證要求順序的完整 node-postgres 程式,以及 PostgreSQL 14 的替代方案。本範例不涵蓋變更文章的父項。
為什麼可為 NULL 的父項會讓位置唯一性變複雜?
假設每篇文章都有 id、可為 NULL 的 parent_id,以及正整數 position。父項相同的文章會形成一個有序的同層群組。在此模型中,所有父項為 null 的文章都屬於同一個根群組。
單純的 UNIQUE (parent_id, position) 無法強制遵守根群組規則。PostgreSQL 通常在唯一性檢查中將 null 視為彼此不同,因此兩篇根文章可以同時占用位置 1。加入 NULLS NOT DISTINCT 後,檢查複合鍵時會將這些 null 父項值視為相同。不同的非 null 父項仍可各自擁有位置 1。PostgreSQL:唯一約束
重新排序還會帶來另一個時序問題。如果文章 1 位於位置 1,而文章 2 位於位置 2,將文章 2 移至位置 1 時,會暫時與文章 1 的位置重複。非延後唯一約束會立即檢查每筆新增或修改的資料列。延後約束則可等到陳述式結束時檢查,或在明確延後時等到交易結束才檢查。PostgreSQL:CREATE TABLE
若重新排序是透過多個更新完成,這兩項特性都很重要:識別 null 的唯一性規則定義正確的群組,而延後檢查則允許多個更新之間暫時發生位置衝突。
依據 PostgreSQL 版本,應使用哪種定義?
PostgreSQL 15 為唯一約束和索引引入了 NULLS NOT DISTINCT。以下主要範例要求伺服器版本為 15 或更新版本;PostgreSQL 14 則需使用後文所示的替代方案。請檢查目前連線的伺服器,而不要從已安裝的命令列用戶端推測伺服器版本。PostgreSQL 15 版本資訊
| 情況 | 支援的做法 |
|---|---|
| PostgreSQL 15 或更新版本,新資料表 | 使用命名的 UNIQUE NULLS NOT DISTINCT 約束,並設為 DEFERRABLE INITIALLY IMMEDIATE |
| 現有的一般唯一約束 | 替換其定義;ALTER CONSTRAINT 無法變更唯一約束的延後屬性 |
| 現有的運算式或部分唯一索引 | 替換其強制執行方式;此類索引無法附加為一般唯一約束 |
| PostgreSQL 14 | 使用產生的分組欄位搭配可延後唯一約束 |
ALTER TABLE 文件說明了現有物件的限制:以此方式變更約束屬性僅適用於外鍵,而附加為唯一約束的索引不能包含運算式,也不能是部分索引。獨立的 CREATE UNIQUE INDEX 也沒有 DEFERRABLE 子句。PostgreSQL:ALTER TABLE、PostgreSQL:CREATE INDEX
如何在 SQL 中交換兩篇根文章的位置?
先決條件是已有 PostgreSQL 15 或更新版本的本機資料庫,且 psql 連線可正常運作。開啟 Mac Terminal,使用已設定的連線設定啟動用戶端。以下命令會略過啟動自訂設定,並在發生錯誤時停止執行指令碼。PostgreSQL:psql
psql -X -v ON_ERROR_STOP=1
將下方完整的 SQL 示範貼到該工作階段中。它會建立一個只包含合成文章的暫存資料表;工作階段結束時,暫存資料表就會消失。該工作階段中不可已有同名資料表。PostgreSQL:暫存資料表
SHOW server_version;
CREATE TEMP TABLE article_order_demo (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
CONSTRAINT article_order_demo_parent_position_key
UNIQUE NULLS NOT DISTINCT (parent_id, position)
DEFERRABLE INITIALLY IMMEDIATE
);
INSERT INTO article_order_demo (id, parent_id, title, position)
VALUES
(1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2),
(3, NULL, 'Article C', 3),
(4, 1, 'Article A: Overview', 1),
(5, 1, 'Article A: Examples', 2);
BEGIN;
SET CONSTRAINTS article_order_demo_parent_position_key DEFERRED;
UPDATE article_order_demo
SET position = 1
WHERE id = 2 AND parent_id IS NULL;
UPDATE article_order_demo
SET position = 2
WHERE id = 1 AND parent_id IS NULL;
SET CONSTRAINTS article_order_demo_parent_position_key IMMEDIATE;
COMMIT;
SELECT id, title, position
FROM article_order_demo
WHERE parent_id IS NULL
ORDER BY position;
第一次更新後,交易中會暫時有兩篇根文章位於位置 1。第二次更新後,它們的位置會再次恢復唯一。將約束切回 IMMEDIATE 時,會在該時點檢查尚未驗證的變更。若仍有重複,該命令就會失敗;否則交易即可提交。延後檢查會改變驗證發生的時機。PostgreSQL:SET CONSTRAINTS
預期的根文章最終順序如下:
| 位置 | 文章 ID | 標題 |
|---|---|---|
| 1 | 2 | 文章 B |
| 2 | 1 | 文章 A |
| 3 | 3 | 文章 C |
子文章會保留原有位置,因為兩次更新都未選取它們。如果互動式交易遇到錯誤,請先輸入 ROLLBACK; 再重試。交易會將相關陳述式一併納入,直到 COMMIT 或 ROLLBACK 為止。PostgreSQL:BEGIN
如何使用 node-postgres 重新排序完整的同層群組?
整個交易都要使用同一個已取出的用戶端。node-postgres 明確要求如此:不同的 pool.query() 呼叫可能使用不同連線,因此不適合用於交易。node-postgres:交易
下方程式接受完整且有序的同層 ID 清單。它採用的額外規則很簡單:提供的清單必須是目前同層集合的排列。拒絕重複項目、遺漏項目以及來自其他群組的 ID,可避免看似成功但實際只完成部分的重新排序。
在本機 Node.js 專案中,安裝 pg,將下方完整程式存為 reorder.cjs,然後執行。程式假設現有的本機連線設定可透過 PostgreSQL 環境變數取得。node-postgres 支援與 psql 相同的連線環境變數。node-postgres:連線
npm install pg
node reorder.cjs
const { Pool } = require('pg');
function validateOrder(ids, rows) {
if (!Array.isArray(ids) || !ids.every(Number.isSafeInteger) ||
new Set(ids).size !== ids.length) {
throw new Error('Order must contain distinct integer IDs');
}
const existing = new Set(rows.map(row => row.id));
if (rows.length !== ids.length || ids.some(id => !existing.has(id))) {
throw new Error('Order must include every sibling exactly once');
}
}
async function main() {
const pool = new Pool();
let client;
try {
client = await pool.connect();
await client.query(`
CREATE TEMP TABLE article_order_demo (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
CONSTRAINT article_order_demo_parent_position_key
UNIQUE NULLS NOT DISTINCT (parent_id, position)
DEFERRABLE INITIALLY IMMEDIATE
)
`);
await client.query(`
INSERT INTO article_order_demo (id, parent_id, title, position)
VALUES (1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2),
(3, NULL, 'Article C', 3),
(4, 1, 'Article A: Overview', 1),
(5, 1, 'Article A: Examples', 2)
`);
const parentId = null;
const orderedIds = [2, 1, 3];
await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
try {
await client.query(
'LOCK TABLE article_order_demo IN SHARE ROW EXCLUSIVE MODE'
);
const siblings = await client.query(
`SELECT id FROM article_order_demo
WHERE parent_id IS NOT DISTINCT FROM $1::integer`,
[parentId]
);
validateOrder(orderedIds, siblings.rows);
await client.query(
'SET CONSTRAINTS article_order_demo_parent_position_key DEFERRED'
);
for (const [index, id] of orderedIds.entries()) {
const result = await client.query(
`UPDATE article_order_demo SET position = $1
WHERE id = $2
AND parent_id IS NOT DISTINCT FROM $3::integer`,
[index + 1, id, parentId]
);
if (result.rowCount !== 1) {
throw new Error('Expected exactly one updated article');
}
}
await client.query(
'SET CONSTRAINTS article_order_demo_parent_position_key IMMEDIATE'
);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
const result = await client.query(
`SELECT id, title, position FROM article_order_demo
WHERE parent_id IS NOT DISTINCT FROM $1::integer
ORDER BY position`,
[parentId]
);
console.table(result.rows);
} finally {
if (client) client.release();
await pool.end();
}
}
main().catch(error => {
console.error(error.message);
process.exitCode = 1;
});
IS NOT DISTINCT FROM 可讓父項篩選條件同時適用於 null 和非 null 值。一般的等號比較搭配 null 參數時,不會選取根文章。若要改為重新排序範例中的子文章,請將 parentId 改為 1,並將 orderedIds 改為 [5, 4]。PostgreSQL:比較函式與運算子
$1 形式的佔位符會將值與 SQL 文字分開傳遞。明確的整數型別轉換也會在父項參數為 null 時,標示其預期型別。node-postgres:查詢
為什麼要在讀取同層文章前取得鎖定?
暫存示範沒有競爭中的工作階段。資料表鎖展示了一種刻意採用的寬鬆做法,適用於小型共用資料表:SHARE ROW EXCLUSIVE 會與一般資料表寫入及自身衝突,但允許一般讀取。在整個交易期間持有該鎖,可讓驗證與更新期間的同層成員保持穩定。它也會阻擋其他父項群組的寫入,因此在忙碌的資料表上使用前,請先評估這項取捨。PostgreSQL:明確鎖定
程式明確選用 READ COMMITTED。在此隔離層級下,同層查詢會看到查詢開始時已提交的變更,包括前一個鎖定等待結束前已提交的變更。PostgreSQL:BEGIN、PostgreSQL:交易隔離
已使用有效排列,以及含重複、缺少和來自其他群組 ID 的情況,檢查 JavaScript 驗證邏輯。這些確定性檢查皆已通過。資料庫範例已依據文件檢視,但未在研究環境中對 PostgreSQL 執行。
PostgreSQL 14 支援哪種替代方案?
在 PostgreSQL 14 中,可將分組運算式實體化為產生欄位,再對這些欄位套用可延後唯一約束。儲存型產生欄位會在寫入資料列時計算,且無法以一般值覆寫。PostgreSQL 14:產生欄位
此獨立 SQL 範例使用兩個產生值表示父項群組。布林值可區分根文章與真正父項 ID 0,因此 COALESCE 預設值不需要保留某個 ID。
CREATE TEMP TABLE article_order_demo14 (
id integer PRIMARY KEY,
parent_id integer REFERENCES article_order_demo14(id),
title text NOT NULL,
position integer NOT NULL CHECK (position > 0),
parent_is_root boolean
GENERATED ALWAYS AS (parent_id IS NULL) STORED,
parent_group integer
GENERATED ALWAYS AS (COALESCE(parent_id, 0)) STORED,
CONSTRAINT article_order_demo14_parent_position_key
UNIQUE (parent_is_root, parent_group, position)
DEFERRABLE INITIALLY IMMEDIATE
);
INSERT INTO article_order_demo14 (id, parent_id, title, position)
VALUES (1, NULL, 'Article A', 1),
(2, NULL, 'Article B', 2);
BEGIN;
SET CONSTRAINTS article_order_demo14_parent_position_key DEFERRED;
UPDATE article_order_demo14 SET position = 1 WHERE id = 2;
UPDATE article_order_demo14 SET position = 2 WHERE id = 1;
SET CONSTRAINTS article_order_demo14_parent_position_key IMMEDIATE;
COMMIT;
SELECT id, title, position
FROM article_order_demo14
WHERE parent_id IS NULL
ORDER BY position;
根文章的產生分組值為 (true, 0);父項為 0 時,產生值為 (false, 0)。每個非 null 父項都會保留自己的整數值。因此,此約束能表達預期的分組方式,不必依賴 PostgreSQL 15 對 null 的處理方式。PostgreSQL 14 支援對具名欄位建立可延後唯一約束。PostgreSQL 14:CREATE TABLE
修改現有資料表前應檢查什麼?
對 COALESCE(parent_id, 0) 建立運算式索引可以強制執行運算式型唯一性,但無法直接改為可延後,或附加為一般唯一約束。根文章的部分索引也有相同的附加限制。若保留這些即時檢查的物件,新增可延後約束後,交換位置仍可能受阻。PostgreSQL:運算式索引、PostgreSQL:ALTER TABLE
替換現有定義前,請檢查父項與位置的重複配對,尤其是過去因一般 nullable 唯一性規則而獲准存在的根文章。新增唯一約束時會驗證現有資料列;唯一約束不支援 NOT VALID。請先審慎處理不明確的位置,再嘗試變更。PostgreSQL:ALTER TABLE
請將約束所保證的條件與應用程式的排序規則分開考量。唯一性本身允許位置出現 1、4 和 9 這類間隔。完整清單程式會刻意從 1 開始指派連續位置。它的成員資格檢查可確保每篇同層文章都參與排序。
對於此任務,請選用能表示可為 null 父項群組的約束,在第一次可能產生衝突的更新前延後檢查,驗證完整的要求同層集合,並在提交前於同一個用戶端上完成所有更新。這些做法結合後,就能重新排序根文章和子文章,同時確保每個同層群組的最終位置唯一。
來源
- CREATE TABLE — PostgreSQL Global Development Group。
- 5.5. Constraints — PostgreSQL Global Development Group。
- E.20. Release 15 — PostgreSQL Global Development Group。
- ALTER TABLE — PostgreSQL Global Development Group。
- CREATE INDEX — PostgreSQL Global Development Group。
- psql — PostgreSQL Global Development Group。
- SET CONSTRAINTS — PostgreSQL Global Development Group。
- BEGIN — PostgreSQL Global Development Group。
- Transactions — node-postgres。
- Connecting — node-postgres。
- Queries — node-postgres。
- 9.2. Comparison Functions and Operators — PostgreSQL Global Development Group。
- 13.3. Explicit Locking — PostgreSQL Global Development Group。
- 13.2. Transaction Isolation — PostgreSQL Global Development Group。
- 5.3. Generated Columns — PostgreSQL Global Development Group。
- CREATE TABLE — PostgreSQL 14 — PostgreSQL Global Development Group。
- 11.7. Indexes on Expressions — PostgreSQL Global Development Group。