web-dev-qa-db-fra.com

Problème PostgreSQL UPSERT avec des valeurs NULL

Je rencontre un problème avec l'utilisation de la nouvelle fonctionnalité UPSERT dans Postgres 9.5

J'ai une table qui est utilisée pour agréger les données d'une autre table. La clé composite est composée de 20 colonnes, dont 10 peuvent être annulées. Ci-dessous, j'ai créé une version plus petite du problème que j'ai, en particulier avec les valeurs 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'exécution de cette requête fonctionne selon les besoins (première insertion, puis les insertions suivantes incrémentent simplement le nombre):

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

Cependant, si j'exécute cette requête, 1 ligne est insérée à chaque fois plutôt que d'incrémenter le nombre de la ligne initiale:

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;

C'est mon problème. Je dois simplement incrémenter la valeur de comptage et ne pas créer plusieurs lignes identiques avec des valeurs nulles.

Tentative d'ajouter un index unique partiel:

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

Cependant, cela donne les mêmes résultats, plusieurs lignes nulles étant insérées ou ce message d'erreur lors de la tentative d'insertion:

ERREUR: il n'y a pas de contrainte unique ou d'exclusion correspondant à la spécification ON CONFLICT

J'ai déjà tenté d'ajouter des détails supplémentaires sur l'index partiel, tels que WHERE test_field is not null OR identifier is not null. Cependant, lors de l'insertion, j'obtiens le message d'erreur de contrainte.

14
Shaun McCready

Clarifier ON CONFLICT DO UPDATE comportement

Considérez le manuel ici :

Pour chaque ligne individuelle proposée pour l'insertion, l'insertion se poursuit ou, si une contrainte ou un index arbitre spécifié par conflict_target est violé, l'alternative conflict_action est pris.

Accentuation sur moi. Vous n'avez donc pas à répéter les prédicats pour les colonnes incluses dans l'index unique de la clause WHERE à UPDATE (le 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 violation unique établit déjà ce que votre clause WHERE ajoutée appliquerait de manière redondante.

Clarifier l'index partiel

Ajoutez une clause WHERE pour en faire un réel index partiel comme vous l'avez mentionné vous-même (mais avec une logique inversée):

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

Pour utiliser cet index partiel dans votre UPSERT vous avez besoin d'un conflict_targetcomme le montre @ypercube :

ON CONFLICT (name, status) WHERE test_field IS NULL

Maintenant, l'indice partiel ci-dessus est déduit. Cependant , comme le manuel note également :

[...] un index unique non partiel (un index unique sans prédicat) sera déduit (et donc utilisé par ON CONFLICT) si un tel indice satisfaisant à tous les autres critères est disponible.

Si vous avez un index supplémentaire (ou uniquement) sur seulement (name, status) il sera (également) utilisé. Un index sur (name, status, test_field) serait explicitement pas inféré. Cela n'explique pas votre problème, mais peut avoir ajouté à la confusion lors du test.

Solution

AIUI, rien de ce qui précède ne résout votre problème, pour l'instant. Avec l'index partiel, seuls les cas spéciaux avec des valeurs NULL correspondantes seraient interceptés. Et d'autres lignes en double seraient insérées si vous n'avez pas d'autres index/contraintes uniques correspondants, ou déclencheraient une exception si vous en avez. Je suppose que ce n'est pas ce que tu veux. Vous écrivez:

La clé composite est composée de 20 colonnes, dont 10 peuvent être annulées.

Que considérez-vous exactement comme un doublon? Postgres (selon la norme SQL) ne considère pas deux valeurs NULL égales. Le manuel:

En général, une contrainte unique est violée s'il existe plusieurs lignes dans le tableau où les valeurs de toutes les colonnes incluses dans la contrainte sont égales. Cependant, deux valeurs nulles ne sont jamais considérées comme égales dans cette comparaison. Cela signifie que même en présence d'une contrainte unique, il est possible de stocker des lignes en double qui contiennent une valeur nulle dans au moins une des colonnes contraintes. Ce comportement est conforme à la norme SQL, mais nous avons entendu que d'autres bases de données SQL pourraient ne pas suivre cette règle. Soyez donc prudent lorsque vous développez des applications destinées à être portables.

En relation:

je suppose vous voulez que les valeurs de NULL dans les 10 colonnes nullables soient considérées comme égales. Il est élégant et pratique de couvrir une seule colonne nullable avec un index partiel supplémentaire comme illustré ici:

Mais cela devient rapidement incontrôlable pour les colonnes plus nullables. Vous auriez besoin d'un index partiel pour chaque combinaison distincte de colonnes nullables. Pour seulement 2 de ces 3 index partiels pour (a), (b) et (a,b). Le nombre augmente de façon exponentielle avec 2^n - 1. Pour vos 10 colonnes nullables, pour couvrir toutes les combinaisons possibles de valeurs NULL, vous auriez déjà besoin de 1023 index partiels. Ne pas aller.

La solution simple: remplacer les valeurs NULL et définir les colonnes impliquées NOT NULL, et tout fonctionnerait très bien avec une simple contrainte UNIQUE.

Si ce n'est pas une option, je suggère un index d'expression avec COALESCE pour remplacer NULL dans l'index:

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

La chaîne vide ('') est un candidat évident pour les types de caractères, mais vous pouvez utiliser n'importe quel valeur légale qui n'apparaît jamais ou peut être pliée avec NULL selon votre définition de "unique".

Utilisez ensuite cette instruction:

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

Comme @ypercube, je suppose que vous voulez réellement ajouter count au décompte existant. Étant donné que la colonne peut être NULL, l'ajout de NULL définirait la colonne NULL. Si vous définissez count NOT NULL, vous pouvez simplifier.


Une autre idée serait de simplement supprimer le conflict_target de l'instruction pour couvrir toutes les violations uniques. Ensuite, vous pouvez définir divers index uniques pour une définition plus sophistiquée de ce qui est censé être "unique". Mais ça ne volera pas avec ON CONFLICT DO UPDATE. Le manuel encore une fois:

Pour ON CONFLICT DO NOTHING, il est facultatif de spécifier un conflict_target; lorsqu'il est omis, les conflits avec toutes les contraintes utilisables (et les index uniques) sont traités. Pour ON CONFLICT DO UPDATE, un conflict_target doit être fourni.

15

Je pense que le problème est que vous n'avez pas d'index partiel et que le ON CONFLICT la syntaxe ne correspond pas à la test_upsert_upsert_id_idx index mais l'autre contrainte unique.

Si vous définissez l'index comme partiel (avec 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 ;

et ces lignes déjà dans le tableau:

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

alors la requête réussira:

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

avec les résultats suivants:

('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
7
ypercubeᵀᴹ