Problema PostgreSQL UPSERT con valores NULL

13

Tengo un problema con el uso de la nueva función UPSERT en Postgres 9.5

Tengo una tabla que se usa para agregar datos de otra tabla. La clave compuesta está compuesta por 20 columnas, 10 de las cuales pueden ser anulables. A continuación, he creado una versión más pequeña del problema que estoy teniendo, específicamente con 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)
);

La ejecución de esta consulta funciona según sea necesario (Primero inserte, luego las inserciones posteriores simplemente incrementan el conteo):

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

Sin embargo, si ejecuto esta consulta, se inserta 1 fila cada vez en lugar de aumentar el recuento de la fila 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;

Este es mi problema Necesito simplemente incrementar el valor de conteo y no crear múltiples filas idénticas con valores nulos.

Intentando agregar un índice único parcial:

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

Sin embargo, esto produce los mismos resultados, ya sea que se inserten varias filas nulas o este mensaje de error al intentar insertar:

ERROR: no existe una restricción de exclusión o única que coincida con la especificación ON CONFLICT

Ya intenté agregar detalles adicionales en el índice parcial como WHERE test_field is not null OR identifier is not null. Sin embargo, al insertar recibo el mensaje de error de restricción.

Shaun McCready
fuente

Respuestas:

14

Aclarar el ON CONFLICT DO UPDATEcomportamiento

Considere el manual aquí :

Para cada fila individual propuesta para la inserción, la inserción continúa o, si conflict_targetse viola una restricción o índice de árbitro especificado por , conflict_actionse toma la alternativa .

El énfasis audaz es mío. Por lo tanto, no tiene que repetir predicados para columnas incluidas en el índice único en la WHEREcláusula de 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'

La violación única ya establece lo que su WHEREcláusula adicional aplicaría de forma redundante.

Aclarar índice parcial

Agregue una WHEREcláusula para convertirlo en un índice parcial real como lo mencionó usted mismo (pero con 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 en su UPSERT, necesita una coincidencia como @ypercube demuestra :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Ahora se infiere el índice parcial anterior. Sin embargo , como el manual también señala :

[...] un índice único no parcial (un índice único sin predicado) será inferido (y por lo tanto utilizado por ON CONFLICT) si dicho índice que satisface todos los demás criterios está disponible.

Si tiene un índice adicional (o solo) solo (name, status)se usará (también). No se inferiría (name, status, test_field)explícitamente un índice en . Esto no explica su problema, pero puede haber aumentado la confusión durante las pruebas.

Solución

AIUI, ninguno de los anteriores resuelve su problema , todavía. Con el índice parcial, solo se detectarían casos especiales con valores NULL coincidentes. Y otras filas duplicadas se insertarían si no tiene otros índices / restricciones únicos coincidentes, o generará una excepción si lo tiene. Supongo que eso no es lo que quieres. Usted escribe:

La clave compuesta está compuesta por 20 columnas, 10 de las cuales pueden ser anulables.

¿Qué es exactamente lo que consideras un duplicado? Postgres (según el estándar SQL) no considera que dos valores NULL sean iguales. El manual:

En general, se infringe una restricción única si hay más de una fila en la tabla donde los valores de todas las columnas incluidas en la restricción son iguales. Sin embargo, dos valores nulos nunca se consideran iguales en esta comparación. Eso significa que incluso en presencia de una restricción única, es posible almacenar filas duplicadas que contienen un valor nulo en al menos una de las columnas restringidas. Este comportamiento se ajusta al estándar SQL, pero hemos escuchado que otras bases de datos SQL podrían no seguir esta regla. Por lo tanto, tenga cuidado al desarrollar aplicaciones destinadas a ser portátiles.

Relacionado:

Supongo que desea que losNULLvalores en las 10 columnas anulables se consideren iguales. Es elegante y práctico cubrir una sola columna anulable con un índice parcial adicional como se muestra aquí:

Pero esto se sale de control rápidamente para obtener más columnas anulables. Necesitaría un índice parcial para cada combinación distinta de columnas anulables. Para solo 2 de esos, son 3 índices parciales para (a), (b)y (a,b). El número está creciendo exponencialmente con 2^n - 1. Para sus 10 columnas anulables, para cubrir todas las combinaciones posibles de valores NULL, ya necesitaría 1023 índices parciales. No vayas.

La solución simple: reemplazar los valores NULL y definir las columnas involucradas NOT NULL, y todo funcionaría bien con una UNIQUErestricción simple .

Si esa no es una opción, sugiero un índice de expresión con COALESCEpara reemplazar NULL en el índice:

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

La cadena vacía ( '') es un candidato obvio para los tipos de caracteres, pero puede usar cualquier valor legal que nunca aparezca o que se pueda plegar con NULL de acuerdo con su definición de "único".

Luego usa esta declaración:

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

Al igual que @ypercube, supongo que realmente desea agregar countal recuento existente. Dado que la columna puede ser NULL, agregar NULL establecería la columna NULL. Si define count NOT NULL, puede simplificar.


Otra idea sería simplemente eliminar el conflict_target de la declaración para cubrir todas las violaciones únicas . Entonces podría definir varios índices únicos para una definición más sofisticada de lo que se supone que es "único". Pero eso no volará con ON CONFLICT DO UPDATE. El manual una vez más:

Para ON CONFLICT DO NOTHING, es opcional especificar un conflict_target; cuando se omite, se manejan los conflictos con todas las restricciones utilizables (y los índices únicos). Para ON CONFLICT DO UPDATE, se debe proporcionar un conflicto_target .

Erwin Brandstetter
fuente
1
Agradable. Me salté la parte de 20-10 columnas la primera vez que leí la pregunta y no tuve tiempo de completarla más tarde. El count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDpuede ser simplificado acount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ
Mirando de nuevo, mi versión "simplificada" no es tan autodocumentada.
ypercubeᵀᴹ
@ ypercubeᵀᴹ: apliqué su actualización sugerida. Es más simple, gracias.
Erwin Brandstetter
@ErwinBrandstetter eres el mejor
Seamus Abshere
7

Creo que el problema es que no tienes un índice parcial y la ON CONFLICTsintaxis no coincide con el test_upsert_upsert_id_idxíndice, sino con la otra restricción única.

Si define el índice como parcial (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 ;

y estas filas ya en la tabla:

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

entonces la consulta tendrá é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

con los siguientes 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ᵀᴹ
fuente
Esto aclara cómo usar un índice parcial. Pero (creo) todavía no resuelve el problema.
Erwin Brandstetter
¿no debería el recuento de 'maria' permanecer en 1 ya que no ocurre ninguna actualización?
mpprdev
@mpprdev sí, tienes razón.
ypercubeᵀᴹ