带有 NULL 值的 PostgreSQL UPSERT 问题

Sha*_*ady 20 postgresql null upsert unique-constraint postgresql-9.5

我在 Postgres 9.5 中使用新的 UPSERT 功能时遇到问题

我有一个表,用于从另一个表聚合数据。复合键由 20 列组成,其中 10 列可以为空。下面我创建了我遇到的问题的较小版本,特别是 NULL 值。

CREATE TABLE public.test_upsert (
upsert_id serial,
name character varying(32) NOT NULL,
status integer NOT NULL,
test_field text,
identifier character varying(255),
count integer,
CONSTRAINT upsert_id_pkey PRIMARY KEY (upsert_id),
CONSTRAINT test_upsert_name_status_test_field_key UNIQUE (name, status, test_field)
);
Run Code Online (Sandbox Code Playgroud)

根据需要运行此查询(首先插入,然后插入只会增加计数):

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun',1,'test value','ident', 1)
ON CONFLICT (name,status,test_field) DO UPDATE set count = tu.count + 1 
where tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = 'test value';
Run Code Online (Sandbox Code Playgroud)

但是,如果我运行此查询,则每次插入 1 行而不是增加初始行的计数:

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun',1,null,'ident', 1)
ON CONFLICT (name,status,test_field) DO UPDATE set count = tu.count + 1  
where tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = null;
Run Code Online (Sandbox Code Playgroud)

这是我的问题。我需要简单地增加计数值,而不是创建多个具有空值的相同行。

尝试添加部分唯一索引:

CREATE UNIQUE INDEX test_upsert_upsert_id_idx
ON public.test_upsert
USING btree
(name COLLATE pg_catalog."default", status, test_field, identifier);
Run Code Online (Sandbox Code Playgroud)

但是,这会产生相同的结果,要么插入多个空行,要么在尝试插入时出现此错误消息:

错误:没有与 ON CONFLICT 规范匹配的唯一或排除约束

我已经尝试在部分索引上添加额外的详细信息,例如WHERE test_field is not null OR identifier is not null. 但是,插入时我收到约束错误消息。

Erw*_*ter 24

澄清ON CONFLICT DO UPDATE行为

考虑这里的手册

对于建议插入的每个单独的行,要么继续插入,要么,如果conflict_target违反了由 指定的仲裁器约束或索引 ,则采用替代方案conflict_action

大胆强调我的。因此,您不必为(the )WHERE子句中的唯一索引中包含的列重复谓词:UPDATEconflict_action

INSERT INTO test_upsert AS tu
       (name   , status, test_field  , identifier, count) 
VALUES ('shaun', 1     , 'test value', 'ident'   , 1)
ON CONFLICT (name, status, test_field) DO UPDATE
SET count = tu.count + 1;
WHERE tu.name = 'shaun' AND tu.status = 1 AND tu.test_field = 'test value'
Run Code Online (Sandbox Code Playgroud)

独特的违规行为已经确定了您添加的WHERE条款将多余地强制执行的内容。

澄清部分索引

添加一个WHERE子句以使其成为您自己提到的实际部分索引(但具有反向逻辑):

CREATE UNIQUE INDEX test_upsert_partial_idx
ON public.test_upsert (name, status)
WHERE test_field IS NULL;  -- not: "is not null"
Run Code Online (Sandbox Code Playgroud)

要在你的 UPSERT 中使用这个部分索引,你需要一个像 @ypercube 演示的匹配:conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL
Run Code Online (Sandbox Code Playgroud)

现在推断出上述部分索引。但是,正如手册中还指出的

[...] 一个非部分唯一索引(一个没有谓词的唯一索引)将被推断(并因此被 使用ON CONFLICT)如果这样的索引满足所有其他标准可用。

如果您有一个额外的(或唯一的)索引,(name, status)它将(也)被使用。上的索引(name, status, test_field)将明确不能推断。这并不能解释您的问题,但可能会增加测试时的混乱。

解决方案

AIUI,以上都不能解决您的问题。使用部分索引,只有匹配 NULL 值的特殊情况才会被捕获。如果没有其他匹配的唯一索引/约束,则将插入其他重复行,否则会引发异常。我想那不是你想要的。你写:

复合键由 20 列组成,其中 10 列可以为空。

你到底认为什么是重复的?Postgres(根据 SQL 标准)不认为两个 NULL 值相等。手册:

通常,如果表中有多于一行且约束中包含的所有列的值相等,则违反唯一约束。但是,在此比较中,从不认为两个空值相等。这意味着即使存在唯一约束,也可以在至少一个受约束的列中存储包含空值的重复行。这种行为符合 SQL 标准,但我们听说其他 SQL 数据库可能不遵循此规则。所以在开发可移植的应用程序时要小心。

有关的:

我假设您希望将NULL所有 10 个可为空列中的值视为相等。用附加的部分索引覆盖单个可为空的列是优雅和实用的,如下所示:

但是对于更多可为空的列,这很快就会失控。对于可空列的每个不同组合,您都需要一个部分索引。对于(a),(b)和的 3 个部分索引中的 2 个(a,b)。数量呈指数增长2^n - 1。对于 10 个可为空的列,要涵盖 NULL 值的所有可能组合,您已经需要 1023 个部分索引。不行。

简单的解决方案:替换 NULL 值并定义相关列NOT NULL,一切都可以在简单的UNIQUE约束下正常工作。

如果这不是一个选项,我建议使用表达式索引COALESCE来替换索引中的 NULL:

CREATE UNIQUE INDEX test_upsert_solution_idx
    ON test_upsert (name, status, COALESCE(test_field, ''));
Run Code Online (Sandbox Code Playgroud)

空字符串 ( '') 显然是字符类型的候选者,但您可以使用任何合法值,这些值要么永远不会出现,要么可以根据对“唯一”的定义与 NULL 折叠。

然后使用这个语句:

INSERT INTO test_upsert as tu(name,status,test_field,identifier, count) 
VALUES ('shaun', 1, null        , 'ident', 11)  -- works with
     , ('bob'  , 2, 'test value', 'ident', 22)  -- and without NULL
ON     CONFLICT (name, status, COALESCE(test_field, '')) DO UPDATE  -- match expr. index
SET    count = COALESCE(tu.count + EXCLUDED.count, EXCLUDED.count, tu.count);
Run Code Online (Sandbox Code Playgroud)

像@ypercube 一样,我假设您实际上想添加count到现有计数中。由于该列可以为 NULL,因此添加 NULL 会将列设置为 NULL。如果定义count NOT NULL,则可以简化。


另一个想法是从语句中删除冲突目标以涵盖所有独特的违规行为。然后,您可以定义各种唯一索引,以便对应该是“唯一”的内容进行更复杂的定义。但这不会与ON CONFLICT DO UPDATE. 手册再次:

对于ON CONFLICT DO NOTHING,指定一个 conflict_target 是可选的;省略时,处理与所有可用约束(和唯一索引)的冲突。对于ON CONFLICT DO UPDATE必须提供一个冲突目标。


ype*_*eᵀᴹ 7

我认为问题在于您没有部分索引,并且ON CONFLICT语法与test_upsert_upsert_id_idx索引不匹配,但与其他唯一约束不匹配。

如果您将索引定义为部分(带有WHERE test_field IS NULL):

CREATE UNIQUE INDEX test_upsert_upsert_id_idx
ON public.test_upsert
USING btree
(name COLLATE pg_catalog."default", status)
WHERE test_field IS NULL ;
Run Code Online (Sandbox Code Playgroud)

这些行已经在表中:

INSERT INTO test_upsert as tu
    (name, status, test_field, identifier, count) 
VALUES 
    ('shaun', 1, null, 'ident', 1),
    ('maria', 1, null, 'ident', 1) ;
Run Code Online (Sandbox Code Playgroud)

那么查询就会成功:

INSERT INTO test_upsert as tu
    (name, status, test_field, identifier, count) 
VALUES 
    ('peter', 1,   17, 'ident', 1),
    ('shaun', 1, null, 'ident', 3),
    ('maria', 1, null, 'ident', 7)
ON CONFLICT 
    (name, status) WHERE test_field IS NULL   -- the conflicting condition
DO UPDATE SET
    count = tu.count + EXCLUDED.count 
WHERE                                         -- when to update
    tu.name = 'shaun' AND tu.status = 1 ;     -- if you don't want all of the
                                              -- updates to happen
Run Code Online (Sandbox Code Playgroud)

结果如下:

('peter', 1,   17, 'ident', 1)  -- no conflict: row inserted

('shaun', 1, null, 'ident', 3)  -- conflict: no insert
                           -- matches where: row updated with count = 1+3 = 4

('maria', 1, null, 'ident', 1)  -- conflict: no insert
                     -- doesn't match where: no update
Run Code Online (Sandbox Code Playgroud)