如何插入包含外键的行?

使用PostgreSQL v9.1。我有以下表格:
CREATE TABLE foo
(
    id BIGSERIAL     NOT NULL UNIQUE PRIMARY KEY,
    type VARCHAR(60) NOT NULL UNIQUE
);

CREATE TABLE bar
(
    id BIGSERIAL NOT NULL UNIQUE PRIMARY KEY,
    description VARCHAR(40) NOT NULL UNIQUE,
    foo_id BIGINT NOT NULL REFERENCES foo ON DELETE RESTRICT
);

假设第一个表 foo 的数据如下:

INSERT INTO foo (type) VALUES
    ( 'red' ),
    ( 'green' ),
    ( 'blue' );

有没有办法通过引用foo表来轻松地插入行到bar中?还是我必须分两步进行,首先查找我想要的foo类型,然后再将新行插入到bar中?
这里是一个伪代码示例,展示了我希望能够实现的功能:
INSERT INTO bar (description, foo_id) VALUES
    ( 'testing',     SELECT id from foo WHERE type='blue' ),
    ( 'another row', SELECT id from foo WHERE type='red'  );

3这是一个措辞恰当的问题,正是我正在寻找的,谢谢你的发帖。 - Adam Hughes
3个回答

你的语法几乎没问题,只需要在子查询周围加上括号就可以了:
INSERT INTO bar (description, foo_id) VALUES
    ( 'testing',     (SELECT id from foo WHERE type='blue') ),
    ( 'another row', (SELECT id from foo WHERE type='red' ) );

DB-Fiddle上进行了测试。

另一种方式,如果您要插入大量值,可以使用更简短的语法:

WITH ins (description, type) AS
( VALUES
    ( 'more testing',   'blue') ,
    ( 'yet another row', 'green' )
)  
INSERT INTO bar
   (description, foo_id) 
SELECT 
    ins.description, foo.id
FROM 
  foo JOIN ins
    ON ins.type = foo.type ;

3读了几遍之后,我现在明白你提供的第二个解决方案了。我喜欢它。现在我正在使用它来在系统首次启动时用一些已知的值初始化我的数据库。 - Stéphane
为什么你说“如果你有很多插入的话”是指第二个选项?是因为它更快还是更容易写? - cikatomo
如果你要插入100行数据,比较一下第一种和第二种方法。第二种方法会更简洁。 - ypercubeᵀᴹ

简单的INSERT
INSERT INTO bar (description, foo_id)
SELECT val.description, f.id
FROM  (
   VALUES
      (text 'testing', text 'blue')  -- explicit type declaration; see below
    , ('another row' , 'red' )
    , ('new row1'    , 'purple')      -- purple does not exist in foo, yet
    , ('new row2'    , 'purple')
   ) val (description, type)
LEFT   JOIN foo f USING (type);
  • LEFT [OUTER] JOIN 而不是 [INNER] JOIN 意味着在 val 中的所有行都会被保留,即使在 foo 中找不到匹配项。相反,NULL 会被输入到 foo_id(如果该列定义为 NOT NULL,则会引发异常)。

  • 子查询中的 VALUES 表达式与 @ypercube's CTE 执行相同的操作。Common Table Expressions 提供了额外的功能,并且在处理大型查询时更易于阅读,但它们也可能成为优化障碍(直到 Postgres 12)。当不需要上述任何功能时,子查询通常会稍微快一些。

  • 您可能需要进行显式类型转换。由于 VALUES 表达式没有直接连接到表(例如 INSERT ... VALUES ...),因此无法推断出类型,并且除非显式指定类型,否则将使用默认数据类型。这在某些情况下可能无法正常工作。只需在第一行中执行此操作,其余行会自动适应。

同时插入缺失的外键行

为了在foo中动态创建缺失的条目,使用单个SQL语句,CTEs起到了关键作用:

WITH sel AS (
   SELECT val.description, val.type, f.id AS foo_id
   FROM  (
      VALUES
         (text 'testing', text 'blue')
       , ('another row', 'red'   )
       , ('new row1'   , 'purple')
       , ('new row2'   , 'purple')
      ) val (description, type)
   LEFT   JOIN foo f USING (type)
   )
, ins AS ( 
   INSERT INTO foo (type)
   SELECT DISTINCT type FROM sel WHERE foo_id IS NULL
   RETURNING id AS foo_id, type
   )
INSERT INTO bar (description, foo_id)
SELECT sel.description, COALESCE(sel.foo_id, ins.foo_id)
FROM   sel
LEFT   JOIN ins USING (type);

Postgres 9.6的旧sqlfiddle - 在9.1中也是一样的。还请参见下面的新示例!

请注意要插入的两行附加行。它们都是紫色,在foo中不存在。这是为了说明第一个INSERT语句中需要使用DISTINCT两行

逐步解释

第一个CTE(Common Table Expression)`sel`提供了多行输入数据。子查询`val`使用`VALUES`表达式可以被替换为作为源的表或子查询。立即通过`LEFT JOIN`连接到`foo`,以附加预先存在的`type`行的`foo_id`。这样,所有其他行都会得到`foo_id IS NULL`。
第二个CTE `ins`将新的不同类型(`foo_id IS NULL`)插入到`foo`中,并返回新生成的`foo_id` - 与`type`一起用于连接回插入的行。
最后的外部`INSERT`现在可以为每一行插入一个`foo_id`:要么该类型已经存在,要么它在步骤2中被插入。
严格来说,这两个插入操作是“并行”进行的,但由于这是一个“单一”的语句,所以默认的FOREIGN KEY约束不会报错。引用完整性默认在语句结束时强制执行。
如果同时运行多个这样的查询,可能会出现极小的竞争条件。请参考以下链接:
- [Postgres中的原子UPDATE .. SELECT](link1) - [函数中的SELECT或INSERT是否容易出现竞争条件?](link2) - [如何在可串行化隔离级别下实现插入-如果不存在则找到事务?](link3)
只有在高并发负载下才会真正发生,而且机会非常小,与其他答案中宣传的缓存解决方案相比,几乎可以忽略不计。

重复使用的函数

创建一个SQL函数,该函数以复合类型的数组作为参数,并在VALUES表达式的位置使用unnest(param)
或者,如果这样的数组语法看起来太混乱,请使用逗号分隔的字符串作为参数_param。例如形式如下:
'description1,type1;description2,type2;description3,type3'

然后使用这个来替换上述语句中的VALUES表达式:
SELECT split_part(x, ',', 1) AS description
       split_part(x, ',', 2) AS type
FROM   unnest(string_to_array(_param, ';')) x;

在Postgres 9.5或更高版本中使用UPSERT的函数

创建一个用于参数传递的自定义行类型。虽然我们可以不使用它,但这样更简单:

CREATE TYPE foobar AS (description text, type text);

功能:

CREATE OR REPLACE FUNCTION f_insert_foobar(VARIADIC _val foobar[])
  RETURNS void
  LANGUAGE sql AS
$func$
WITH val AS (SELECT * FROM unnest(_val))    -- well-known row type
,    typ AS (
   SELECT v.type, f.id                      -- id NOT NULL where type already exists
   FROM  (SELECT DISTINCT type FROM val) v  -- DISTINCT!
   LEFT   JOIN foo f USING (type)           -- assuming no concurrent update/delete on foo
                                            -- else you might lock rows here.
   )
,    ins AS ( 
   INSERT INTO foo AS f (type)
   SELECT type
   FROM   typ
   WHERE  id IS NULL
   ON     CONFLICT (type) DO UPDATE         -- RARE cases of concurrent inserts
   SET    type = EXCLUDED.type              -- overwrite to make visible
   RETURNING f.type, f.id
   )
INSERT INTO bar AS b (description, foo_id)
SELECT v.description, COALESCE(t.id, i.id)  -- assuming most types pre-exist
FROM        val v
LEFT   JOIN typ t USING (type)              -- already existed
LEFT   JOIN ins i USING (type)              -- newly inserted
ON     CONFLICT (description) DO UPDATE     -- description already exists
SET    foo_id = EXCLUDED.foo_id             -- real UPSERT this time
WHERE  b.foo_id <> EXCLUDED.foo_id;         -- only if actually changed
$func$;

致电:
SELECT f_insert_foobar(
     '(testing,blue)'
   , '(another row,red)'
   , '(new row1,purple)'
   , '(new row2,green)'
   , '("with,comma",green)'  -- added to demonstrate row syntax
   );

db<>fiddle here

快速且稳定,适用于具有并发事务的环境。

除了上述查询之外,此函数还...

  • ... 在 foo 上应用 SELECTINSERT:任何在外键表中不存在的 type 都会被插入,假设大多数类型已经存在。

  • ... 在 bar 上应用 INSERTUPDATE(真正的 "UPSERT"):如果 description 已经存在,则更新其 type,但仅当它实际发生变化时才更新。参见:

  • ... 将值作为众所周知的行类型传递给具有 VARIADIC 函数参数。请注意,默认最大函数参数为100!参见:

    还有许多其他传递多行的方法...

相关:


在你的“同时插入缺失的外键行”示例中,将其放在一个事务中是否会减少在SQL Server中出现竞态条件的风险? - element11
1@element11: 回答是针对Postgres的,但由于我们正在讨论一个单独的 SQL命令,无论如何它都是一个单独的事务。在更大的事务中执行它只会增加可能出现竞争条件的时间窗口。至于SQL Server:不支持修改数据的CTE(通用表达式),只支持WITH子句中的SELECT操作。参考来源: MS文档 - Erwin Brandstetter
1你也可以在psql中使用INSERT ... RETURNING \gset来完成这个操作,然后将返回的值作为psql的:'variables'来使用,但是这种方法只适用于单行插入。 - Craig Ringer
@ErwinBrandstetter 这太棒了,但我对SQL还不够了解,无法完全理解它,你能在"插入同时缺失的外键行"这部分添加一些注释来解释它是如何工作的吗?另外,感谢你提供的SQLFiddle工作示例! - glallen
@glallen:我添加了一份逐步解释。还有许多相关答案和手册链接,提供更详细的解释。你必须理解查询的作用,否则可能会超出你的能力范围。 - Erwin Brandstetter
恭喜你的简洁陈述。我知道MySQL直到8.0版本才开始支持CTE,那么在没有CTE的情况下,这个简洁陈述在MySQL中是否可行呢? - Jon
@Jon:我不这么认为。但请将你的新问题以问题的形式提出。你可以随时引用这个问题作为背景。 - Erwin Brandstetter
非常出色的解释和例子! - Int'l Man Of Coding Mystery
干得好,@ErwinBrandstetter!有一个小问题,如果要插入的值为NULL,但它不是字符串类型,这意味着我们必须显式地转换它们的数据类型,那该怎么办?除了在插入时使用CAST(col AS target_data_type)之外,我想不出其他解决方案。 - Memphis Meng
@MemphisMeng: 这要看情况。我的函数 f_insert_foo_bar() 使用明确定义的输入类型,因此对于未定义类型的字符串字面量,不需要显式转换。如果至少有一个赋值转换被定义,类型化的输入也会自动强制转换。相关链接:https://stackoverflow.com/a/12427434/939860 如果还有什么不清楚的地方,请提出一个新问题,并附上与您情况相关的详细信息。 - Erwin Brandstetter

查询。你基本上需要foo id来将它们插入bar。
顺便说一下,并不是只有Postgres特定的(而且你没有像那样标记它)- 这通常是SQL的工作方式。这里没有捷径。
就应用程序而言,你可能在内存中有一个foo项目的缓存。我的表通常有多达3个唯一字段:
- Id(整数或其他类型),是表级别的主键。 - 标识符,是一个GUID,在应用程序级别上用作稳定的ID(可能会在URL等地方对客户公开)。 - 代码 - 如果存在,则必须是唯一的字符串(SQL Server上:过滤非空的唯一索引)。这是一个客户集合标识符。
例如:
- Account(在交易应用程序中) -> Id是用于外键的整数。 -> 标识符是一个GUID,在Web门户等地方使用 - 始终被接受。 -> 代码是手动设置的。规则:一旦设置,就不会改变。
显然,当你想将某些内容链接到一个账户时 - 从技术上讲,你首先必须获取Id - 但是考虑到一旦Identifier和Code添加后就永远不会改变,正面的内存缓存可以阻止大部分的数据库查询。

11你知道可以让关系数据库管理系统(RDBMS)为你进行查找,只需一条SQL语句,避免容易出错的缓存吗? - Erwin Brandstetter
你知道查找不变元素是不容易出错的吗?而且通常关系型数据库管理系统(RDBMS)不具备可扩展性,也是游戏中最昂贵的元素之一,因为需要支付许可费用。尽可能减轻其负载并非完全是坏事。此外,并不是很多对象关系映射(ORM)工具一开始就支持这一点。 - TomTom
16不变的元素?最昂贵的元素是什么?PostgreSQL的许可费用?ORM定义了什么是合理的?不,我对这一切都不知情。 - Erwin Brandstetter
嗨!正在学习SQL。所以,即使每个条目都有唯一的ID,你仍然需要其他唯一字段来帮助在数据库中进行查找,这是你的意思吗?我看了页面上的其他解决方案,想知道这个问题。你的回答在底部,似乎证实了这一点。 - DeltaFlyer
"非变化的元素?" - 是的,一个账户就是非变化的。"许可费用(针对PostgreSQL)?" - 是的,最好以一种被高端数据库如Oracle嘲笑的方式来学习它。毕竟,成为非专业人士是一种美德。"ORM定义什么是明智的?" - 不,ORM定义什么是常见的。明智另当别论,但许多ORM几乎不允许所有功能。而且鉴于ORM将开发成本降低了约40%,不关心它是疯狂的。但在你们的世界里,金钱似乎长在树上。 - TomTom