复合 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,并在查询时从该文章获取版本。

来源