Problème PostgreSQL UPSERT avec des valeurs NULL

13

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.

Shaun McCready
la source

Réponses:

14

Clarifier le ON CONFLICT DO UPDATEcomportement

Considérez le manuel ici :

Pour chaque ligne individuelle proposée pour l'insertion, l'insertion se poursuit ou, si une contrainte d'arbitre ou un index spécifié par conflict_targetest violé, l'alternative conflict_actionest prise.

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

Clarifier l'index partiel

Ajoutez une WHEREclause pour en faire un index partiel réel 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'une correspondance comme @ypercube le démontre :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

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

[...] un indice 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 seulement) juste, (name, status)il sera (également) utilisé. Un indice sur (name, status, test_field)ne serait explicitement pas déduit. 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 encore votre problème . 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 que vous voulez que lesNULLvaleurs des 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 UNIQUEcontrainte.

Si ce n'est pas une option, je suggère un index d'expression avec COALESCEpour 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 quelle 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 countau 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 cela ne volera pas avec ON CONFLICT DO UPDATE. Le manuel une fois de plus:

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.

Erwin Brandstetter
la source
1
Agréable. J'ai sauté la partie des 20 à 10 colonnes la première fois que j'ai lu la question et je n'ai pas eu le temps de terminer plus tard. Le count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDpeut être simplifié pourcount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ
En y repensant, ma version "simplifiée" n'est pas si auto-documentée.
ypercubeᵀᴹ
@ ypercubeᵀᴹ: J'ai appliqué la mise à jour que vous avez suggérée. C'est plus simple, merci.
Erwin Brandstetter
@ErwinBrandstetter vous êtes le meilleur
Seamus Abshere
7

Je pense que le problème est que vous n'avez pas d'index partiel et que la ON CONFLICTsyntaxe ne correspond pas à l' test_upsert_upsert_id_idxindex 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
ypercubeᵀᴹ
la source
Cela clarifie la façon d'utiliser un index partiel. Mais (je pense) cela ne résout pas encore le problème.
Erwin Brandstetter
le nombre de 'maria' ne devrait-il pas rester à 1 car aucune mise à jour ne se produit?
mpprdev
@mpprdev oui, vous avez raison.
ypercubeᵀᴹ