将 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
如何诊断意外结果?
按删除路径逐步诊断:
- 确认引用表。 在包含这些表的会话中检查
\d demo_articles和\d demo_sections。检查实际的外键操作,而不是根据列名推测。PostgreSQL 文档指出,\d可用于查看表详情和约束名称。修改表 - 检查可空性和其他约束。 如果删除合集失败,请确认被清除的列允许为空,且其他约束接受更改后的行。
SET NULL不会绕过这些约束。约束 - 检查所有相关外键。 其他关系可能采用不同的操作。未指定操作时,默认值为
NO ACTION;如果仍有引用,这可能会阻止删除。CREATE TABLE - 解读连接结果之前,先查询基表。 如果文章仍在基表中,请检查连接和筛选条件,它们可能会排除新变为 null 的引用。表表达式
- 在同一客户端上执行事务语句。 在使用连接池的应用程序中,为整个事务获取同一个客户端。node-postgres 明确建议不要使用
pool.query()执行事务语句。事务
对于此文章模型,验收测试很明确:删除合集 1 后,文章 101 及其章节都应保留;删除文章 101 后,只应保留文章 102 的章节。调整架构时,请一并保留这两项检查,以便清楚地验证预期的保留和删除行为。
来源
- 约束 — PostgreSQL Global Development Group
- CREATE TABLE — PostgreSQL Global Development Group
- psql — PostgreSQL Global Development Group
- 表表达式 — PostgreSQL Global Development Group
- DELETE — PostgreSQL Global Development Group
- 事务 — PostgreSQL Global Development Group
- PostgreSQL 15 发行说明 — PostgreSQL Global Development Group
- 修改表 — PostgreSQL Global Development Group
- node-postgres — node-postgres
- 事务 — node-postgres
- 查询 — node-postgres
- 连接 — node-postgres