Problema UPSERT PostgreSQL con valori NULL


13

Sto riscontrando un problema con l'utilizzo della nuova funzione UPSERT in Postgres 9.5

Ho una tabella che viene utilizzata per aggregare i dati da un'altra tabella. La chiave composita è composta da 20 colonne, 10 delle quali possono essere nullable. Di seguito ho creato una versione più piccola del problema che sto riscontrando, in particolare con i valori 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)
);

L'esecuzione di questa query funziona in base alle esigenze (primo inserimento, quindi inserimenti successivi semplicemente incrementano il conteggio):

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

Tuttavia, se eseguo questa query, 1 riga viene inserita ogni volta anziché aumentare il conteggio per la riga iniziale:

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;

Questo è il mio problema Devo semplicemente aumentare il valore del conteggio e non creare più righe identiche con valori null.

Tentativo di aggiungere un indice univoco parziale:

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

Tuttavia, ciò produce gli stessi risultati, inserendo più righe null o questo messaggio di errore quando si tenta di inserire:

ERRORE: non esiste alcun vincolo univoco o di esclusione corrispondente alla specifica ON CONFLICT

Ho già tentato di aggiungere ulteriori dettagli sull'indice parziale come WHERE test_field is not null OR identifier is not null. Tuttavia, durante l'inserimento ottengo il messaggio di errore del vincolo.

Risposte:


14

Chiarire ON CONFLICT DO UPDATE comportamento

Considera il manuale qui :

Per ogni singola riga proposta per l'inserimento, procede l'inserimento oppure, se conflict_targetviene violato un vincolo arbitrale o un indice specificato da , conflict_actionviene presa l'alternativa .

Enorme enfasi sulla mia. Quindi non è necessario ripetere i predicati per le colonne incluse nell'indice univoco nella WHEREclausola al UPDATE(il 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'

La violazione unica stabilisce già ciò che hai aggiunto WHERE clausola imporrebbe in modo ridondante.

Chiarire l'indice parziale

Aggiungi una WHEREclausola per renderla un indice parziale effettivo come hai menzionato (ma con logica invertita):

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

Per utilizzare questo indice parziale nel tuo UPSERT hai bisogno di una corrispondenza come dimostra @ypercube :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Ora viene dedotto l'indice parziale sopra. Tuttavia , come osserva anche il manuale :

[...] un indice univoco non parziale (un indice univoco senza predicato) verrà dedotto (e quindi utilizzato da ON CONFLICT) se tale indice soddisfa tutti gli altri criteri.

Se hai solo un indice aggiuntivo (o solo) (name, status), verrà (anche) usato. Un indice (name, status, test_field)attivo non lo farebbe esplicitamente essere dedotto. Questo non spiega il tuo problema, ma potrebbe aver aggiunto confusione durante il test.

Soluzione

AIUI, nessuno dei precedenti risolve il tuo problema . Con l'indice parziale, verrebbero rilevati solo casi speciali con valori NULL corrispondenti. E altre righe duplicate verrebbero inserite se non si hanno altri indici / vincoli univoci corrispondenti, o se si crea un'eccezione. Suppongo che non sia quello che vuoi. Scrivi:

La chiave composita è composta da 20 colonne, 10 delle quali possono essere nullable.

Cosa consideri esattamente un duplicato? Postgres (secondo lo standard SQL) non considera due valori NULL uguali. Il manuale:

In generale, un vincolo univoco viene violato se nella tabella è presente più di una riga in cui i valori di tutte le colonne incluse nel vincolo sono uguali. Tuttavia, due valori nulli non sono mai considerati uguali in questo confronto. Ciò significa che anche in presenza di un vincolo univoco è possibile memorizzare righe duplicate che contengono un valore null in almeno una delle colonne vincolate. Questo comportamento è conforme allo standard SQL, ma abbiamo sentito che altri database SQL potrebbero non seguire questa regola. Quindi fai attenzione quando sviluppi applicazioni che devono essere portatili.

Relazionato:

Suppongo che desideri che iNULLvalori in tutte e 10 le colonne annullabili siano considerati uguali. È elegante e pratico coprire una singola colonna nullable con un indice parziale aggiuntivo come mostrato qui:

Ma questo sfugge di mano rapidamente per colonne più annullabili. Avresti bisogno di un indice parziale per ogni combinazione distinta di colonne nullable. Solo per 2 di questi sono 3 indici parziali per (a), (b)e (a,b). Il numero sta crescendo esponenzialmente con 2^n - 1. Per le tue 10 colonne annullabili, per coprire tutte le possibili combinazioni di valori NULL, avresti già bisogno di 1023 indici parziali. Non andare.

La soluzione semplice: sostituire i valori NULL e definire le colonne coinvolte NOT NULL, e tutto funzionerebbe perfettamente con un semplice UNIQUEvincolo.

Se questa non è un'opzione, suggerisco un indice di espressione con COALESCEper sostituire NULL nell'indice:

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

La stringa vuota ( '') è un candidato naturale per i tipi di carattere, ma è possibile utilizzare qualsiasi valore legale che o non appare mai o può essere piegato con NULL in base alla tua definizione di "unico".

Quindi utilizzare questa affermazione:

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

Come @ypercube suppongo che tu voglia davvero aggiungere countal conteggio esistente. Poiché la colonna può essere NULL, l'aggiunta di NULL imposterà la colonna NULL. Se lo definisci count NOT NULL, puoi semplificare.


Un'altra idea sarebbe quella di eliminare il target_target dalla dichiarazione per coprire tutte le violazioni uniche . Quindi potresti definire vari indici unici per una definizione più sofisticata di ciò che dovrebbe essere "unico". Ma questo non volerà con ON CONFLICT DO UPDATE. Il manuale ancora una volta:

Per ON CONFLICT DO NOTHING, è facoltativo specificare un conflitto_target; se omesso, vengono gestiti i conflitti con tutti i vincoli utilizzabili (e gli indici univoci). Per ON CONFLICT DO UPDATE, un conflitto_target deve essere fornito.


1
Bello. Ho saltato la parte 20-10 colonne la prima volta che ho letto la domanda e non ho avuto il tempo di completarla in seguito. Il count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDpuò essere semplificato incount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ

Guardando di nuovo, la mia versione "semplificata" non è così auto-documentante.
ypercubeᵀᴹ

@ ypercubeᵀᴹ: ho applicato l'aggiornamento suggerito. È più semplice, grazie.
Erwin Brandstetter,

@ErwinBrandstetter sei il migliore
Seamus Abshere il

7

Penso che il problema sia che non hai un indice parziale e la ON CONFLICTsintassi non corrisponde test_upsert_upsert_id_idxall'indice ma all'altro vincolo univoco.

Se si definisce l'indice come parziale (con 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 queste righe già nella tabella:

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

quindi la query avrà esito positivo:

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

con i seguenti risultati:

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

Questo chiarisce come utilizzare un indice parziale. Ma (penso) non risolve ancora il problema.
Erwin Brandstetter,

il conteggio di "maria" non dovrebbe rimanere su 1 poiché non si verifica alcun aggiornamento?
mpprdev,

@mpprdev sì, hai ragione.
ypercubeᵀᴹ
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.