Problema no PostgreSQL UPSERT com valores NULL

13

Estou tendo um problema ao usar o novo recurso UPSERT no Postgres 9.5

Eu tenho uma tabela que é usada para agregar dados de outra tabela. A chave composta é composta por 20 colunas, 10 das quais podem ser anuláveis. Abaixo, criei uma versão menor do problema que estou tendo, especificamente com valores 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)
);

A execução dessa consulta funciona conforme o necessário (primeiro insira, depois as inserções subsequentes simplesmente aumentem a contagem):

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';

No entanto, se eu executar esta consulta, uma linha será inserida a cada vez, em vez de incrementar a contagem da linha inicial:

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;

Esse é o meu problema. Eu preciso simplesmente incrementar o valor da contagem e não criar várias linhas idênticas com valores nulos.

Tentativa de adicionar um índice exclusivo parcial:

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

No entanto, isso gera os mesmos resultados, sendo inseridas várias linhas nulas ou esta mensagem de erro ao tentar inserir:

ERRO: não há nenhuma restrição exclusiva ou de exclusão que corresponda à especificação ON CONFLICT

Eu já tentei adicionar detalhes extras no índice parcial, como WHERE test_field is not null OR identifier is not null. No entanto, ao inserir, recebo a mensagem de erro de restrição.

Shaun McCready
fonte

Respostas:

14

Esclarecer o ON CONFLICT DO UPDATEcomportamento

Considere o manual aqui :

Para cada linha individual proposta para inserção, a inserção prossegue ou, se uma restrição ou índice de árbitro especificado por conflict_targetfor violado, a alternativa conflict_actionserá adotada.

Negrito ênfase minha. Portanto, você não precisa repetir predicados para colunas incluídas no índice exclusivo na WHEREcláusula to UPDATE(the conflict_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'

A violação exclusiva já estabelece o que sua WHEREcláusula adicionada aplicaria de forma redundante.

Esclarecer índice parcial

Adicione uma WHEREcláusula para torná-lo um índice parcial real como você se mencionou (mas com lógica invertida):

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

Para usar este índice parcial no seu UPSERT, você precisa de uma correspondência como @ypercube demonstra :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Agora, o índice parcial acima é inferido. No entanto , como o manual também observa :

[...] um índice exclusivo não parcial (um índice exclusivo sem predicado) será inferido (e, portanto, usado por ON CONFLICT) se um índice que satisfaça todos os outros critérios estiver disponível.

Se você tiver um índice adicional (ou apenas) apenas (name, status)esse (também) será usado. Um índice (name, status, test_field)ativado explicitamente não seria inferido. Isso não explica o seu problema, mas pode ter aumentado a confusão durante o teste.

Solução

AIUI, nenhuma das opções acima resolve seu problema , ainda. Com o índice parcial, apenas casos especiais com valores NULL correspondentes seriam capturados. E outras linhas duplicadas seriam inseridas se você não tiver outros índices / restrições exclusivos correspondentes ou, se houver, criarão uma exceção. Suponho que não é isso que você quer. Você escreve:

A chave composta é composta por 20 colunas, 10 das quais podem ser anuláveis.

O que exatamente você considera uma duplicata? O Postgres (de acordo com o padrão SQL) não considera dois valores NULL iguais. O manual:

Em geral, uma restrição exclusiva é violada se houver mais de uma linha na tabela em que os valores de todas as colunas incluídas na restrição são iguais. No entanto, dois valores nulos nunca são considerados iguais nessa comparação. Isso significa que, mesmo na presença de uma restrição exclusiva, é possível armazenar linhas duplicadas que contêm um valor nulo em pelo menos uma das colunas restritas. Esse comportamento está em conformidade com o padrão SQL, mas ouvimos dizer que outros bancos de dados SQL podem não seguir esta regra. Portanto, tenha cuidado ao desenvolver aplicativos que se destinam a serem portáteis.

Palavras-chave:

Suponho que você queira que osNULLvalores em todas as 10 colunas anuláveis ​​sejam considerados iguais. É elegante e prático cobrir uma única coluna anulável com um índice parcial adicional, como demonstrado aqui:

Mas isso fica fora de controle rapidamente para mais colunas anuláveis. Você precisaria de um índice parcial para cada combinação distinta de colunas anuláveis. Para apenas 2 deles, são 3 índices parciais para (a), (b)e (a,b). O número está crescendo exponencialmente com 2^n - 1. Para suas 10 colunas anuláveis, para cobrir todas as combinações possíveis de valores NULL, você já precisaria de 1023 índices parciais. Não vá.

A solução simples: substitua valores NULL e defina as colunas envolvidas NOT NULL, e tudo funcionaria bem com uma UNIQUErestrição simples .

Se isso não for uma opção, sugiro um índice de expressão com COALESCEpara substituir NULL no índice:

CREATE UNIQUE INDEX test_upsert_solution_idx
    ON test_upsert (name, status, COALESCE(test_field, ''));

A string vazia ( '') é uma candidata óbvia aos tipos de caracteres, mas você pode usar qualquer valor legal que nunca apareça ou possa ser dobrado com NULL, de acordo com sua definição de "exclusivo".

Então use esta declaração:

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);

Como @ypercube, suponho que você realmente deseja adicionar countà contagem existente. Como a coluna pode ser NULL, a adição de NULL definiria a coluna NULL. Se você definir count NOT NULL, poderá simplificar.


Outra idéia seria simplesmente retirar o conflito_target da declaração para cobrir todas as violações exclusivas . Em seguida, você pode definir vários índices exclusivos para uma definição mais sofisticada do que deveria ser "exclusivo". Mas isso não vai acontecer ON CONFLICT DO UPDATE. O manual mais uma vez:

Para ON CONFLICT DO NOTHING, é opcional especificar um destination_target; quando omitido, são tratados conflitos com todas as restrições utilizáveis ​​(e índices exclusivos). Para ON CONFLICT DO UPDATE, deve ser fornecido um target_target.

Erwin Brandstetter
fonte
1
Agradável. Ignorei a parte de 20 a 10 colunas na primeira vez que li a pergunta e não tive tempo para concluir mais tarde. O que count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDpode ser simplificado paracount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeOct
Olhando novamente, minha versão "simplificada" não é tão auto-documentada.
usar o seguinte código
@ ypercubeᵀᴹ: apliquei a atualização sugerida. É mais simples, obrigado.
Erwin Brandstetter
@ErwinBrandstetter você é o melhor #
Seamus Abshere
7

Acho que o problema é que você não possui um índice parcial e a ON CONFLICTsintaxe não corresponde ao test_upsert_upsert_id_idxíndice, mas a outra restrição exclusiva.

Se você definir o índice como parcial (com 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 ;

e estas linhas já estão na tabela:

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

a consulta terá êxito:

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

com os seguintes resultados:

('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
ypercubeᵀᴹ
fonte
Isso esclarece como usar um índice parcial. Mas (eu acho) ainda não resolve o problema.
Erwin Brandstetter 5/10
a contagem de 'maria' não deve permanecer em 1, pois nenhuma atualização acontece?
26417 mpfdev
@mpprdev sim, você está certo.
ypercubeᵀᴹ