PostgreSQL 的 NOT VALID 外鍵會略過對現有資料列的初始檢查,同時強制檢查新的參照。較早的不匹配資料列會保留,直到修復並完成驗證。PostgreSQL 17:ALTER TABLE

本指南適用於在 Mac 的 Terminal 中工作的開發者,協助你將外鍵加入已有資料的資料表,並了解結果。完整的 node-postgres 範例只使用合成的文章資料,並追蹤一筆不匹配的參照,從建立限制條件到修復及成功驗證。

現有資料列與新寫入會發生什麼事?

假設有兩個資料表:articles 包含文章 ID 和標題,article_links 則包含對這些 ID 的參照。若連結中的非空 article_id 沒有對應的文章,該連結就是不匹配的。使用 NOT VALID 加入外鍵,可讓較早的不匹配狀況繼續存在,同時對之後的寫入建立限制。PostgreSQL 17:ALTER TABLE

加入限制條件後的操作 此範例中的結果
不修改較早的不匹配連結 該連結仍會保留
插入參照現有文章的連結 成功
插入參照不存在文章的連結 失敗
將連結的文章 ID 改成另一個不存在的 ID 失敗
不匹配連結仍存在時進行驗證 驗證失敗
修復所有不匹配的參照後再驗證 驗證可成功

這些結果源自文件所述的限制條件加入與驗證流程分離機制。PostgreSQL 17:ALTER TABLE

有一個重要例外:更新若未改變外鍵值,結果可能不同。在 PostgreSQL 18 中,若新舊鍵值比較相等,實作可以略過外鍵檢查。因此,只修改較早的不匹配連結的標籤,可能會成功,而不必修復其參照。實作也對目前交易所插入的資料列設有特殊處理,因此不應將此行為概括套用到所有更新順序。PostgreSQL 18 外鍵實作

實際上,標籤修改成功並不能證明清理工作已完成。請直接檢查不匹配的參照,並使用驗證確認整個資料表都符合限制條件。

需要哪個 PostgreSQL 版本和資料表結構?

以下範例使用一般的非分割資料表,適用於 PostgreSQL 17 和 18。分割資料表有一個重要的版本差異:PostgreSQL 17 明確禁止將分割資料表上的外鍵宣告為 NOT VALID;PostgreSQL 18 則新增了這項功能。請檢查伺服器版本,不要根據已安裝的 Node 套件推測是否支援。PostgreSQL 17:ALTER TABLE、PostgreSQL 18 版本說明

若 PostgreSQL 17 上的參照資料表是分割資料表,可直接採用的支援流程是先修正參照,再以一般方式加入外鍵,讓系統執行初始驗證掃描。若必須在分割資料表的父資料表上分階段驗證,PostgreSQL 18 提供了文件記載的功能。本教學採用簡單的資料表配置,避免分割階層細節模糊了資料列行為。

被參照的鍵也需要適當的唯一性保證。在此,articles.article_id 是主鍵。參照欄位刻意允許 NULL:根據預設的 MATCH SIMPLE 規則,空值參照不需要對應的文章。若每個連結都必須指向文章,請另外加入 NOT NULL。PostgreSQL 18:CREATE TABLE

在 Mac 上執行完整的合成資料範例

你需要一個正在執行的 PostgreSQL 資料庫、一份已設定完成且可用的本機連線設定,以及 node-postgres 支援的 Node.js 版本。在 Terminal 的專案目錄中,使用以下命令安裝套件,然後將完整程式儲存為 not-valid-demo.mjs。node-postgres 官方入門文件說明了套件安裝方式。node-postgres 文件

npm install pg

程式使用 new pg.Client(),它會讀取 PostgreSQL 連線環境變數,例如 PGHOST、PGPORT、PGDATABASE 和 PGUSER。請使用目前已能連線至本機資料庫的設定。node-postgres:連線

兩個資料表都是暫存資料表,只在目前連線的工作階段中存在。PostgreSQL 規定暫存資料表的外鍵只能參照暫存資料表,因此程式會同時建立關聯兩端的資料表。PostgreSQL 18:CREATE TABLE

每項操作都會使用獨立的已等待查詢。值會透過 $1 和 $2 等 node-postgres 參數傳入;資料表名稱和限制條件名稱則固定寫在 SQL 中。node-postgres:查詢

import pg from 'pg';

const client = new pg.Client({ connectionTimeoutMillis: 5000 });
const validate = `ALTER TABLE pg_temp.article_links
  VALIDATE CONSTRAINT article_links_article_fk`;

async function expectForeignKeyError(label, sql, values = []) {
  try {
    await client.query(sql, values);
  } catch (error) {
    if (error.code !== '23503' ||
        error.constraint !== 'article_links_article_fk') throw error;
    console.log(label + ': 23503 (expected)');
    return;
  }
  throw new Error(label + ': unexpectedly succeeded');
}

async function showStatus(label) {
  const result = await client.query(`
    SELECT conname, convalidated
    FROM pg_constraint
    WHERE conrelid = 'pg_temp.article_links'::regclass
      AND conname = 'article_links_article_fk'
      AND contype = 'f'
  `);
  console.log(label, result.rows);
}

try {
  await client.connect();
  console.log((await client.query('SHOW server_version')).rows);
  await client.query(`CREATE TEMP TABLE articles (
    article_id integer PRIMARY KEY,
    title text NOT NULL
  )`);
  await client.query(`CREATE TEMP TABLE article_links (
    link_id integer PRIMARY KEY,
    article_id integer,
    label text NOT NULL
  )`);
  await client.query(
    'INSERT INTO pg_temp.articles VALUES ($1, $2)',
    [1, 'Desk arrangement']
  );
  await client.query(`INSERT INTO pg_temp.article_links VALUES
    (10, 1, 'Matching article'),
    (11, 99, 'Older unmatched reference')`);
  await client.query(`ALTER TABLE pg_temp.article_links
    ADD CONSTRAINT article_links_article_fk
    FOREIGN KEY (article_id)
    REFERENCES pg_temp.articles (article_id) NOT VALID`);
  await showStatus('After adding');

  await expectForeignKeyError(
    'New unmatched reference',
    'INSERT INTO pg_temp.article_links VALUES ($1, $2, $3)',
    [12, 99, 'New reference']
  );
  await expectForeignKeyError(
    'Changed unmatched reference',
    'UPDATE pg_temp.article_links SET article_id = $1 WHERE link_id = $2',
    [98, 11]
  );
  await client.query(
    'UPDATE pg_temp.article_links SET label = $1 WHERE link_id = $2',
    ['Revised label', 11]
  );
  console.log('Label-only update succeeded');

  const unmatched = await client.query(`
    SELECT l.link_id, l.article_id, l.label
    FROM pg_temp.article_links AS l
    WHERE l.article_id IS NOT NULL
      AND NOT EXISTS (
        SELECT 1 FROM pg_temp.articles AS a
        WHERE a.article_id = l.article_id
      )
    ORDER BY l.link_id
  `);
  console.log('Unmatched references', unmatched.rows);
  await expectForeignKeyError('Validation before repair', validate);
  await showStatus('After failed validation');

  await client.query(
    'UPDATE pg_temp.article_links SET article_id = $1 WHERE link_id = $2',
    [1, 11]
  );
  await client.query(validate);
  await showStatus('After successful validation');
} finally {
  await client.end();
}

在同一個專案目錄中執行已儲存的程式:

node not-valid-demo.mjs

準備期間,JavaScript 程式通過了 Node 語法檢查。研究環境無法執行資料庫;以下結果是依據文件記載的行為推導出的預期結果,並非擷取到的執行記錄。

如何解讀結果?

首先,限制條件應會出現在目錄中,其 convalidated 值為 false。系統目錄將此欄位定義為限制條件是否已驗證。查詢會同時依資料表名稱和限制條件名稱篩選,因為限制條件名稱在資料庫中不一定唯一。PostgreSQL:pg_constraint

接著,新增不匹配連結的插入操作,以及變更為不匹配參照的操作,都應各自產生 23503。PostgreSQL 將該 SQLSTATE 標示為 foreign_key_violation,並建議檢查錯誤代碼,而非比對可能因語系而異的訊息文字。輔助函式只接受預期的代碼和限制條件;任何非預期錯誤都會讓程式停止。PostgreSQL 錯誤代碼

只更新標籤後,連結 11 仍會指向文章 99。因此,診斷查詢應只回傳這筆連結,其標籤現在為 Revised label。明確指定的 IS NOT NULL 條件會排除可選的空值參照,而 NOT EXISTS 則會找出不存在於 articles 中的非空 ID。這些都是根據提供的合成資料列推導出的示意結果。

此時進行驗證應會失敗。修復操作將連結 11 改為參照文章 1 後,驗證應會成功,而 convalidated 應會變成 true。VALIDATE CONSTRAINT 會掃描資料表,檢查先前未驗證的限制條件;它本身不會修復資料。PostgreSQL:ALTER TABLE

在此示範中,修復方式是明確的編輯決定:我們預期兩個連結都指向 Desk arrangement。對於你自己的文章資料,請先檢查每筆不匹配參照,再決定要修正 ID、補上缺少的文章,還是移除已過時的連結。將所有不匹配的 ID 自動重新導向至同一篇文章,會掩蓋資料庫無法判斷的差異。

如何將此流程套用到已有資料的資料表?

分成三個獨立階段:加入並提交 NOT VALID 限制條件、檢查並修復不匹配的參照,然後進行驗證。加入外鍵會對兩個資料表取得 SHARE ROW EXCLUSIVE 鎖。驗證會對參照資料表取得 SHARE UPDATE EXCLUSIVE 鎖,並對被參照資料表取得 ROW SHARE 鎖,因此驗證掃描期間仍可執行一般的並行更新。不過,取得鎖仍可能需要等待。PostgreSQL:ALTER TABLE

將初次加入與後續驗證分開放在不同交易中,可保留分階段作法的用意:較長的掃描開始前,初次加入限制條件的交易即可先完成。請將加入限制條件安排為短時間操作,並將診斷查詢視為協助清理的工具,以成功驗證作為完成步驟。

此示範刻意不使用明確的交易區塊,讓程式能逐一處理預期中的陳述式失敗。如果你要將流程改用 node-postgres 交易,請自行執行 BEGIN、COMMIT 和 ROLLBACK,並確保交易中的每個陳述式都使用同一個用戶端。node-postgres 文件明確指出,交易陳述式不可使用 pool.query()。node-postgres:交易

請一併參考範例中的三項觀察結果:不匹配的資料列指出需要修復的項目,拒絕新的不匹配參照顯示限制仍在生效,而 convalidated = true 則記錄驗證成功。這些結果共同提供一個明確的完成標準,讓你能將關聯加入已有資料的文章資料表。

來源