將每種動作指派給不同的外鍵,即可結合 ON DELETE SET NULL 與 ON DELETE CASCADE。本指南使用合成文章示範,刪除集合時如何保留其中的文章,而刪除文章時如何移除其章節。

目標是定義這種行為,從 Mac 的 Terminal 驗證兩種刪除路徑,並使用 node-postgres 重複檢查。範例使用一般外鍵與暫存資料表,因此每次執行都會有自己的範例資料列。

哪種關係應使用哪種刪除動作?

刪除動作屬於參照資料表中的外鍵。被參照的資料列消失時,SET NULL 會清除參照;CASCADE 則會刪除參照資料列。PostgreSQL 文件指出,選用關係適合使用 SET NULL,而相依元件適合使用 CASCADE。Constraints

在此範例中,一個集合包含多篇文章,但文章即使沒有集合也能存在。一個章節隸屬於一篇文章,且沒有獨立用途。以下是此範例明確採用的設計選擇:

外鍵 選用的動作 預期結果
demo_articles.collection_id 參照 demo_collections.collection_id ON DELETE SET NULL 移除集合後,保留其文章,並清除集合參照。
demo_sections.article_id 參照 demo_articles.article_id ON DELETE CASCADE 移除文章時,也移除其章節。

實際行為取決於刪除從何處開始。刪除集合會變更文章的 collection_id,不會刪除文章。因此,章節外鍵的 ON DELETE CASCADE 不會因文章被刪除而觸發。直接刪除文章才會觸發該級聯動作。這是根據文件中對這兩種動作的定義而得出的結果。CREATE TABLE

不要在同一個關係上設定兩個互相競爭的外鍵,試圖表達「清除參照並刪除資料列」。請先決定參照資料列是否應保留,再選擇能實現該決定的動作。

在 Mac 上建立完整範例

你需要一部正在執行的 PostgreSQL 伺服器、一個可連線的現有資料庫,以及可在 Terminal 使用的 psql。以下指令使用名為 postgres 的現有本機資料庫;如有需要,請替換成你的現有資料庫名稱。安裝與伺服器設定不在本指南範圍內。

在工作資料夾中,將以下內容儲存為名為 setup.sql 的純文字檔。三個資料表都是暫存資料表:工作階段結束時,PostgreSQL 會移除暫存資料表。此範例中所有被參照的資料表也都是暫存資料表,因為 PostgreSQL 不允許外鍵跨越暫存資料表與永久資料表。CREATE TABLE

CREATE TEMP TABLE demo_collections (
    collection_id integer PRIMARY KEY,
    title text NOT NULL
);

CREATE TEMP TABLE demo_articles (
    article_id integer PRIMARY KEY,
    collection_id integer,
    title text NOT NULL,
    CONSTRAINT demo_articles_collection_fk
        FOREIGN KEY (collection_id)
        REFERENCES demo_collections (collection_id)
        ON DELETE SET NULL
);

CREATE TEMP TABLE demo_sections (
    section_id integer PRIMARY KEY,
    article_id integer NOT NULL,
    heading text NOT NULL,
    CONSTRAINT demo_sections_article_fk
        FOREIGN KEY (article_id)
        REFERENCES demo_articles (article_id)
        ON DELETE CASCADE
);

CREATE INDEX demo_articles_collection_idx
    ON demo_articles (collection_id);
CREATE INDEX demo_sections_article_idx
    ON demo_sections (article_id);

INSERT INTO demo_collections (collection_id, title) VALUES
    (1, 'Layout Notes'),
    (2, 'Color Notes');

INSERT INTO demo_articles (article_id, collection_id, title) VALUES
    (101, 1, 'Grid Spacing'),
    (102, 2, 'Color Pairings');

INSERT INTO demo_sections (section_id, article_id, heading) VALUES
    (1001, 101, 'Overview'),
    (1002, 101, 'Examples'),
    (1003, 102, 'Overview');

請注意,collection_id 可為空值,而章節資料表中的 article_id 則為必填欄位。SET NULL 動作仍必須符合資料表的其他限制條件。明確建立的索引涵蓋參照欄位;宣告外鍵時,PostgreSQL 不會自動為這些欄位建立索引。Constraints

在同一個工作階段中驗證兩種刪除路徑

將下一段內容儲存為 verify.sql,放在 setup.sql 旁邊。這是一個完整的 psql 指令碼:\ir 會根據驗證指令碼的位置載入設定檔。請透過此指令碼執行兩個檔案,讓暫存資料表維持在同一個工作階段中。-f 選項會執行檔案,而 ON_ERROR_STOP 會在發生錯誤後停止處理指令碼。psql

BEGIN;
\ir setup.sql

DELETE FROM demo_collections
WHERE collection_id = 1
RETURNING collection_id;

SELECT a.article_id,
       a.title,
       a.collection_id,
       c.title AS collection_title,
       count(s.section_id) AS section_count
FROM demo_articles AS a
LEFT JOIN demo_collections AS c
    ON c.collection_id = a.collection_id
LEFT JOIN demo_sections AS s
    ON s.article_id = a.article_id
GROUP BY a.article_id, a.title, a.collection_id, c.title
ORDER BY a.article_id;

DELETE FROM demo_articles
WHERE article_id = 101
RETURNING article_id;

SELECT section_id, article_id, heading
FROM demo_sections
ORDER BY section_id;

ROLLBACK;

在 Terminal 中切換到存放這些檔案的資料夾,然後執行:

psql -X -v ON_ERROR_STOP=1 -d postgres -f verify.sql

第一個查詢的預期結果根據範例資料列推導如下:

文章 刪除集合後的集合 ID 集合標題 章節數
101 NULL NULL 2
102 2 Color Notes 1

文章 101 會連同其兩個章節一併保留。執行第二次刪除後,章節查詢應只傳回章節 1003,它屬於文章 102。這兩項檢查可以區分保留文章,以及刪除文章與其相依資料列這兩種情況。

驗證查詢刻意使用 LEFT JOIN。PostgreSQL 會保留左側資料表中沒有相符資料列的資料列,並為缺少的右側欄位提供空值。如果改用對集合資料表的內部聯結,文章 101 在其參照變成空值後就會被省略,導致保留的文章從查詢結果中消失。Table Expressions

RETURNING 子句會回報從目標資料表刪除的資料列,但不會列出級聯刪除的所有章節;請使用獨立的章節查詢驗證該結果。DELETE

最後的 ROLLBACK 會取消這次示範所做的變更。PostgreSQL 也會將獨立陳述式視為一個交易,因此在此使用明確的交易,能將設定、刪除、檢查與清理操作組成一個單位。Transactions

使用 node-postgres 執行相同檢查

若要使用 JavaScript 版本,請保持 setup.sql 不變。此範例使用 Node.js 24,並在工作資料夾中安裝文件所述的 pg 套件:node-postgres

npm install pg

將以下完整程式儲存為 verify.mjs,放在 setup.sql 旁邊。它會讀取相同的合成測試資料,驗證刪除集合會保留兩篇文章及全部三個章節,接著驗證刪除文章只會移除與該文章相符的章節。

程式會在整個交易期間使用同一個已連線的用戶端,這是 node-postgres 的必要做法。查詢值會透過文件所述的參數陣列介面,以 $1 傳入。Transactions、Queries

import pg from 'pg';
import { readFile } from 'node:fs/promises';
import assert from 'node:assert/strict';

const { Client } = pg;
const client = new Client();
const setup = await readFile(
  new URL('./setup.sql', import.meta.url),
  'utf8'
);

await client.connect();
try {
  await client.query('BEGIN');
  await client.query(setup);

  const removed = await client.query(
    'DELETE FROM demo_collections WHERE collection_id = $1 RETURNING collection_id',
    [1]
  );
  assert.deepEqual(removed.rows, [{ collection_id: 1 }]);

  const articles = await client.query(
    'SELECT article_id, collection_id FROM demo_articles ORDER BY article_id'
  );
  assert.deepEqual(articles.rows, [
    { article_id: 101, collection_id: null },
    { article_id: 102, collection_id: 2 }
  ]);

  const before = await client.query(
    'SELECT section_id FROM demo_sections ORDER BY section_id'
  );
  assert.deepEqual(
    before.rows.map(row => row.section_id),
    [1001, 1002, 1003]
  );

  await client.query(
    'DELETE FROM demo_articles WHERE article_id = $1',
    [101]
  );
  const after = await client.query(
    'SELECT section_id FROM demo_sections ORDER BY section_id'
  );
  assert.deepEqual(after.rows, [{ section_id: 1003 }]);

  console.log('Expected SET NULL and CASCADE outcomes verified.');
  await client.query('ROLLBACK');
} catch (error) {
  try {
    await client.query('ROLLBACK');
  } catch (rollbackError) {
    console.error('Rollback failed:', rollbackError.message);
  }
  throw error;
} finally {
  await client.end();
}

使用同一個現有本機資料庫執行:

PGDATABASE=postgres node verify.mjs

new Client() 會讀取 PostgreSQL 連線環境變數。請保留本機伺服器目前所需的任何連線設定;只變更資料庫名稱並不會設定不同的伺服器。Connecting

如果斷言失敗,程式會進入復原路徑並傳回錯誤。只有所有結果檢查都通過後,才會顯示成功訊息。即使成功,程式也會復原交易,讓這個示範可以重複執行。

準備期間已檢查 JavaScript 語法。研究環境中沒有 PostgreSQL 執行檔,因此資料庫範例是根據文件檢視,而非實際執行;上表列出的是預期結果。

使用複合外鍵時有哪些變化?

主要範例使用單欄外鍵。複合外鍵會帶來另一項決策:刪除時應清除所有參照欄位,還是只清除可選欄位?PostgreSQL 15 加入了外鍵 ON DELETE SET 動作的欄位清單;較早的版本會影響外鍵中的所有欄位。PostgreSQL 15 Release Notes

例如,複合文章參照可能包含必填的版本識別碼,以及可選的相關文章識別碼。在 PostgreSQL 15 或更新版本中,ON DELETE SET NULL (related_article_id) 可以保留版本識別碼,同時清除可選的文章參照。如果沒有欄位清單,SET NULL 會套用至兩個欄位。這種語法是 PostgreSQL 擴充功能,僅適用於 ON DELETE,不適用於 ON UPDATE。CREATE TABLE

部分欄位設為空值也需要相容的比對規則。預設的 MATCH SIMPLE 允許複合外鍵包含空值;MATCH FULL 則會拒絕參照值中同時有空值與非空值的情況。因此,保留必填的版本識別碼、僅將相關文章識別碼設為空值,與 MATCH FULL 不相容。Constraints

在 PostgreSQL 14 中,如果這能準確表示該關係,請採用像主要範例那樣以獨立單欄可選參照建立的結構。如果必須保留複合關係,請在刪除被參照資料列前,於同一個交易中明確清除其可選參照。這是由應用程式管理的操作順序,並非受支援的欄位清單語法。

PostgreSQL 18 還有另一項文件所述的限制:使用 PERIOD 的時間型外鍵不支援 CASCADE 或 SET NULL。當關係不涉及時間長度涵蓋時,本文示範的一般外鍵才是支援這些刪除動作的做法。CREATE TABLE

如何診斷非預期的結果?

依照刪除路徑逐步診斷:

  1. 確認參照資料表。 在包含這些資料表的工作階段中檢查 \d demo_articles 與 \d demo_sections。確認實際的外鍵動作,不要只根據欄位名稱推測。PostgreSQL 文件指出,可使用 \d 檢視資料表詳細資訊與限制條件名稱。Modifying Tables
  2. 檢查可空性與其他限制條件。 如果刪除集合失敗,請確認要清除的欄位允許空值,且其他限制條件接受變更後的資料列。SET NULL 不會略過這些限制。Constraints
  3. 檢查所有相關外鍵。 其他關係可能使用不同動作。未指定動作時,預設為 NO ACTION;若仍有參照,這可能會阻止刪除。CREATE TABLE
  4. 解讀聯結結果前,先查詢基礎資料表。 如果文章仍存在於該資料表中,請檢查聯結與篩選條件是否省略了新的空值參照。Table Expressions
  5. 在同一個用戶端上執行交易陳述式。 在使用連線集區的應用程式中,請為整個交易取得同一個用戶端。node-postgres 明確建議不要使用 pool.query() 執行交易陳述式。Transactions

對此文章模型而言,驗收測試很明確:移除集合 1 後,文章 101 及其章節都應完整保留;移除文章 101 後,則只應留下文章 102 的章節。調整結構時,請一併保留這兩項檢查,讓預期的保留與刪除行為清楚可見。

來源