复合 PostgreSQL 外键需要一个符合条件、涵盖被引用列的唯一键。仅在 id 上设置主键,并不符合对 (id, edition) 的引用要求,尽管从逻辑上说,这会使这对值唯一。PostgreSQL:CREATE TABLE
本指南帮助使用 Mac 上 Terminal 的开发者诊断这种不匹配、选择合适的关系,并通过 node-postgres 测试结果。所有示例行都描述合成文章。SQL 面向 PostgreSQL 18 和基于普通相等比较的外键;PostgreSQL 18 对使用 PERIOD 的时态引用另有规定。PostgreSQL:CREATE TABLE
为什么 PostgreSQL 会拒绝这个复合引用?
假设一张文章表有三列:id、edition 和 title。它的主键是 id。另一张表将文章引用存为两个值:article_id 和 edition。
预期的关系通过以下表约束片段表示:
FOREIGN KEY (article_id, edition)
REFERENCES demo_articles (id, edition)
此声明要求 PostgreSQL 将这两个值作为整体,与一条父表记录进行匹配。根据文档,被引用的目标必须是一个非延迟的唯一约束或主键约束所包含的列,或者非部分唯一索引所包含的列。现有主键只涵盖 id;而该引用指定的是 (id, edition)。PostgreSQL:CREATE TABLE
这种质疑背后的数学推理可以理解:如果没有两条父表记录能共享一个 id,那么它们也不可能共享一对 (id, edition)。然而,这种推论并不能提供外键定义所要求的、与之匹配的已声明目标。对于此架构,添加 UNIQUE (id, edition) 就能提供该目标。这个额外约束在唯一性方面逻辑冗余,但能作为复合引用目标发挥实际作用。
看一下这些示例父表记录:
| id | edition | title |
|---|---|---|
| 101 | morning | Paper Shapes |
| 102 | evening | Color Notes |
子表中的一对 (101, 'morning') 能找到匹配项。一对 (101, 'evening') 则不能:这两个值虽然都出现在表中,却位于不同记录中。当这两个存储值都必须描述同一篇文章时,这正是使用复合关系的原因。
应该添加唯一键,还是简化外键?
修改架构前先确定所需的关系。关键问题是:子表是否需要同时存储并约束与文章标识符对应的版本?
| 所需行为 | 合适的设计 | 结果 |
|---|---|---|
| 子表标识一篇文章,并显示其当前版本 | 存储 article_id;引用父表的 id |
查询时从父表读取版本 |
| 子表存储的版本必须与对应文章一致 | 保留复合引用;在父表中添加 UNIQUE (id, edition) |
PostgreSQL 会检查存储的值对是否共同存在 |
| 同一个文章编号可以标识多个版本记录 | 将父表标识建模为 (id, edition) |
仅在 id 上设置主键会阻止这些父表记录存在 |
最后一行描述的是不同的数据模型。不要仅仅为了消除错误就删除单列主键:应先确定父表是否确实需要包含重复的文章编号。PostgreSQL 允许使用复合主键,而且一张表只能有一个主键。PostgreSQL:约束
下面的可运行示例中,文章标识仍然全局唯一。子表特意也存储版本,因此父表保留 PRIMARY KEY (id),并增加额外的唯一值对。
如何在 Mac 上重现并修复这个错误?
使用现有的本地 PostgreSQL 18 数据库和可用的 Node.js 安装;本演练假设两者都已就绪。打开 Terminal 并创建一个新项目目录。使用文档中的包安装命令安装驱动。node-postgres:欢迎
mkdir composite-fk-demo
cd composite-fk-demo
npm init -y
npm install pg
将以下完整程序保存为 demo.mjs。它会创建临时表、演示被拒绝的架构、添加匹配的键,并测试有效和无效的文章值对。所有语句都使用同一个客户端,因为 node-postgres 要求事务始终使用同一个客户端实例。node-postgres:事务
辅助函数会在每个预期失败的操作周围设置保存点,以便事务能够继续。PostgreSQL 文档说明,ROLLBACK TO SAVEPOINT 会撤销保存点之后的命令,并允许继续执行。PostgreSQL:ROLLBACK TO SAVEPOINT
import pg from 'pg';
const { Client } = pg;
const client = new Client();
const childDDL = `CREATE TEMP TABLE demo_article_links (
link_id integer PRIMARY KEY,
article_id integer NOT NULL,
edition text NOT NULL,
FOREIGN KEY (article_id, edition)
REFERENCES demo_articles (id, edition)
)`;
async function expectError(sql, values, code) {
await client.query('SAVEPOINT expected_error');
try {
await client.query(sql, values);
} catch (error) {
await client.query('ROLLBACK TO SAVEPOINT expected_error');
await client.query('RELEASE SAVEPOINT expected_error');
if (error.code !== code) throw error;
console.log(`Expected error: ${code}`);
return;
}
await client.query('ROLLBACK TO SAVEPOINT expected_error');
await client.query('RELEASE SAVEPOINT expected_error');
throw new Error(`Expected SQLSTATE ${code}, but query succeeded`);
}
try {
await client.connect();
await client.query('BEGIN');
await client.query(`CREATE TEMP TABLE demo_articles (
id integer PRIMARY KEY,
edition text NOT NULL,
title text NOT NULL
)`);
await expectError(childDDL, [], '42830');
await client.query(`ALTER TABLE demo_articles
ADD CONSTRAINT demo_articles_id_edition_key
UNIQUE (id, edition)`);
await client.query(childDDL);
await client.query(`INSERT INTO demo_articles (id, edition, title)
VALUES ($1, $2, $3), ($4, $5, $6)`,
[101, 'morning', 'Paper Shapes', 102, 'evening', 'Color Notes']);
await client.query(`INSERT INTO demo_article_links
(link_id, article_id, edition) VALUES ($1, $2, $3)`,
[1, 101, 'morning']);
await expectError(`INSERT INTO demo_article_links
(link_id, article_id, edition) VALUES ($1, $2, $3)`,
[2, 101, 'evening'], '23503');
const result = await client.query(`SELECT l.link_id, a.title, a.edition
FROM demo_article_links AS l
JOIN demo_articles AS a
ON a.id = l.article_id AND a.edition = l.edition
ORDER BY l.link_id`);
console.table(result.rows);
} catch (error) {
console.error(error.code ?? error.message);
process.exitCode = 1;
} finally {
try { await client.query('ROLLBACK'); }
finally { await client.end(); }
}
程序通过 $1、$2 及后续占位符分别传入文章值,遵循驱动的参数化查询 API。表名和列名在 SQL 中保持固定;PostgreSQL 参数不能代替标识符。node-postgres:查询
使用现有本地数据库的连接设置运行程序。此调用假设数据库名为 article_demo,服务器监听端口 5432,并且现有的本地默认连接设置可用。如有需要,请更改数据库名或端口。node-postgres 会读取 PostgreSQL 连接环境变量。node-postgres:连接
PGHOST=localhost PGPORT=5432 PGDATABASE=article_demo node demo.mjs
本文准备期间对 JavaScript 进行了语法检查。数据库行为依据文档进行了核对;此处未在 PostgreSQL 服务器上执行该程序。
两种错误和最终查询说明了什么?
在满足上述前提条件时,预期消息如下:
预期错误:42830
预期错误:23503
第一个错误发生在创建子表时,早于任何文章行的插入。PostgreSQL 将 42830 标识为 invalid_foreign_key。父表添加匹配的唯一键后,同一个子表定义就会生效。PostgreSQL:错误代码
第二个错误发生在插入 (101, 'evening') 时。此时架构有效,但提供的值对没有对应的父表记录。PostgreSQL 将 23503 标识为 foreign_key_violation。检查错误代码即可区分这种行匹配问题和前一种架构不匹配问题,而不必依赖本地化的错误文本。PostgreSQL:错误代码
最终查询应返回一行:链接 1、标题 Paper Shapes、版本 morning。其连接条件包含关系的两个组成部分,因此查询描述的值对与约束强制执行的值对相同。
两个单独的外键表达的是另一种规则。即使 edition 自身有符合条件的唯一键,对 id 和 edition 分别进行独立引用,也会允许两个值各自匹配不同的父表记录。这个示例清楚展示了两者的区别:被拒绝的值对中的两个单独值都存在,但它们的组合不存在。
最后的 ROLLBACK 会有意丢弃演示内容,包括表定义。要将此设计用于持久表,请通过正常的迁移流程修改架构;复制这个临时表程序不会创建长期有效的应用架构。
修改现有架构前应检查什么?
从被引用的列列表开始。找出实际涵盖该列列表的父键,不要依赖索引名称,也不要仅仅因为当前行恰好各不相同就认为它符合要求。对于本例,需要添加程序中所示的命名约束 UNIQUE (id, edition)。
添加唯一约束会创建唯一 B-tree 索引。在选择复合设计还是只存储 article_id 时,应将这个额外索引纳入考量。外键声明本身不会自动在子表列上创建索引。PostgreSQL:约束
向现有子表添加外键前,请检查其中存储的值对是否与父表记录匹配。有效的目标键只能满足架构要求,无法修复不匹配的子表值。普通的 ALTER TABLE ... ADD CONSTRAINT 会检查现有行。PostgreSQL 还支持先以 NOT VALID 添加外键,再运行 VALIDATE CONSTRAINT;这种方式会推迟对现有行的检查,同时继续对后续插入或更新应用该约束。PostgreSQL:ALTER TABLE
应将验证与目标资格视为两个独立问题。NOT VALID 不能补上缺失的父键,也不能用来添加无需证明唯一性的唯一约束:PostgreSQL 18 仅允许将此选项用于外键、检查和非空约束。PostgreSQL:ALTER TABLE
哪些边界条件会改变结果?
部分唯一索引不符合此处所述普通外键目标的要求。同样,将被引用的唯一约束设为可延迟,也不满足文档对非延迟约束的要求。本例使用简单的默认值 UNIQUE (id, edition)。PostgreSQL:CREATE TABLE
空值处理也很重要。使用默认匹配行为时,只要任何一个引用组件为 null,复合外键就不要求匹配父表记录。本例将两个子表组件都设为 NOT NULL,因此每个存储的值对都必须匹配。如果需要可选关系,MATCH FULL 允许引用值全部为空,但会拒绝部分为空的情况。PostgreSQL:约束
实用的测试方法是检查值对本身:子表能否指定一个文章标识符和一个属于不同记录的版本?如果必须禁止这种情况,就保留复合关系,并为其提供匹配的父键。如果子表只需要标识文章,就引用 id,并在查询时从该文章获取版本。
来源
- PostgreSQL 18:CREATE TABLE — PostgreSQL 全球开发组
- PostgreSQL 18:5.5. 约束 — PostgreSQL 全球开发组
- PostgreSQL 18:ALTER TABLE — PostgreSQL 全球开发组
- PostgreSQL 18:ROLLBACK TO SAVEPOINT — PostgreSQL 全球开发组
- PostgreSQL 18:附录 A. PostgreSQL 错误代码 — PostgreSQL 全球开发组
- 欢迎 — node-postgres
- 查询 — node-postgres
- 事务 — node-postgres
- 连接 — node-postgres