将 ON DELETE SET NULL 和 ON DELETE CASCADE 分配给单独的外键,即可组合使用这两种操作。本指南使用合成文章数据演示:删除合集可以保留其中的文章,而删除文章会移除其章节。

任务是定义这种行为,在 Mac 的 Terminal 中验证两种删除路径,并使用 node-postgres 重复检查。示例使用普通外键和临时表,因此每次运行都会使用各自的示例行。

哪种关系应该使用哪种删除操作?

删除操作属于引用表中的外键。被引用的行消失时,SET NULL 会清除引用;CASCADE 会删除引用行。PostgreSQL 文档指出,可选关系适合使用 SET NULL,从属组件适合使用 CASCADE。约束

在此示例中,一个合集包含多篇文章,但文章可以独立于合集存在。一个章节属于一篇文章,且没有独立用途。这些是本示例明确采用的设计选择:

外键 选择的操作 预期结果
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 不会自动为这些列创建索引。约束

在同一会话中验证两种删除路径

将下一个代码块保存为 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 会保留左表中未匹配的行,并为缺失的右表值提供 null。在合集表上使用内连接,会在文章 101 的引用变成 null 后将其排除,从而让保留下来的文章从查询结果中消失。表表达式

RETURNING 子句会报告从目标表中删除的行。它们不会汇总级联删除的所有章节;单独的章节查询用于验证这一结果。DELETE

最后的 ROLLBACK 会取消本次演示的更改。PostgreSQL 也会将独立语句视为事务,因此显式事务可以在此处将设置、删除、检查和清理归为一组。事务

使用 node-postgres 运行相同的检查

要运行 JavaScript 版本,请保持 setup.sql 不变。本示例使用 Node.js 24,并在工作文件夹中安装文档所述的 pg 软件包:node-postgres

npm install pg

将以下完整程序保存为 verify.mjs,放在 setup.sql 旁边。程序会读取相同的合成样例数据,验证删除合集会保留两篇文章和全部三个章节,然后验证删除文章只会移除与该文章匹配的章节。

根据 node-postgres 的要求,程序会在整个事务中使用同一个已连接的客户端。查询值通过文档所述的参数数组接口使用 $1 传入。事务,查询

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 连接环境变量。保留本地服务器所需的现有连接设置;仅更改数据库名称不会配置不同的服务器。连接

断言失败时,程序会进入回滚路径并抛出错误。只有在所有结果检查通过后,才会显示成功消息。即使成功,程序也会回滚,以便重复运行演示。

准备期间已检查 JavaScript 语法。研究环境中没有 PostgreSQL 可执行文件,因此数据库示例是根据文档进行检查的,并未实际执行;上表列出的是预期结果。

使用复合外键时有哪些变化?

主要示例使用单列外键。复合外键还会带来另一个选择:删除时应该清除所有引用列,还是只清除可选列?PostgreSQL 15 引入了用于外键 ON DELETE SET 操作的列列表;在更早的版本中,外键中的所有列都会受到影响。PostgreSQL 15 发行说明

例如,复合文章引用可能包含必填的版本标识符和可选的相关文章标识符。在 PostgreSQL 15 或更高版本中,ON DELETE SET NULL (related_article_id) 可以保留版本标识符,同时清除可选的文章引用。若不指定列列表,SET NULL 会作用于两列。此语法是 PostgreSQL 扩展,仅适用于 ON DELETE,不适用于 ON UPDATE。CREATE TABLE

部分置空还要求匹配规则兼容。默认的 MATCH SIMPLE 允许复合外键中包含 null;MATCH FULL 则拒绝引用值中 null 与非 null 混合的情况。因此,保留必填的版本标识符、仅将相关文章标识符置空的做法与 MATCH FULL 不兼容。约束

在 PostgreSQL 14 中,如果这能准确表示该关系,请像主示例一样使用包含单列可选引用的架构。如果必须保留复合关系,请在删除被引用的行之前,于一个事务中显式清除其可选引用。这是由应用程序管理的操作顺序,不支持使用列列表语法。

PostgreSQL 18 中还有一项文档说明的限制:使用 PERIOD 的时态外键不支持 CASCADE 或 SET NULL。如果关系不涉及时长覆盖,本文演示的普通外键是使用这些删除操作的受支持方式。CREATE TABLE

如何诊断意外结果?

按删除路径逐步诊断:

  1. 确认引用表。 在包含这些表的会话中检查 \d demo_articles 和 \d demo_sections。检查实际的外键操作,而不是根据列名推测。PostgreSQL 文档指出,\d 可用于查看表详情和约束名称。修改表
  2. 检查可空性和其他约束。 如果删除合集失败,请确认被清除的列允许为空,且其他约束接受更改后的行。SET NULL 不会绕过这些约束。约束
  3. 检查所有相关外键。 其他关系可能采用不同的操作。未指定操作时,默认值为 NO ACTION;如果仍有引用,这可能会阻止删除。CREATE TABLE
  4. 解读连接结果之前,先查询基表。 如果文章仍在基表中,请检查连接和筛选条件,它们可能会排除新变为 null 的引用。表表达式
  5. 在同一客户端上执行事务语句。 在使用连接池的应用程序中,为整个事务获取同一个客户端。node-postgres 明确建议不要使用 pool.query() 执行事务语句。事务

对于此文章模型,验收测试很明确:删除合集 1 后,文章 101 及其章节都应保留;删除文章 101 后,只应保留文章 102 的章节。调整架构时,请一并保留这两项检查,以便清楚地验证预期的保留和删除行为。

来源