PostgreSQL UPSERT-Problem mit NULL-Werten

13

Ich habe ein Problem mit der Verwendung der neuen UPSERT-Funktion in Postgres 9.5

Ich habe eine Tabelle, die zum Aggregieren von Daten aus einer anderen Tabelle verwendet wird. Der zusammengesetzte Schlüssel besteht aus 20 Spalten, von denen 10 nullwertfähig sein können. Unten habe ich eine kleinere Version des Problems erstellt, das ich habe, insbesondere mit NULL-Werten.

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

Das Ausführen dieser Abfrage funktioniert nach Bedarf (Erst Einfügen, dann nachfolgende Einfügen erhöhen einfach die Anzahl):

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

Wenn ich diese Abfrage jedoch ausführe, wird jedes Mal eine Zeile eingefügt, anstatt die Anzahl für die erste Zeile zu erhöhen:

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;

Das ist mein Problem. Ich muss einfach den Zählwert erhöhen und nicht mehrere identische Zeilen mit Nullwerten erstellen.

Versuch, einen partiellen eindeutigen Index hinzuzufügen:

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

Dies führt jedoch zu denselben Ergebnissen, da entweder mehrere Nullzeilen eingefügt werden oder beim Einfügen die folgende Fehlermeldung angezeigt wird:

FEHLER: Es gibt keine eindeutige oder Ausschlussbedingung, die mit der ON CONFLICT-Spezifikation übereinstimmt

Ich habe bereits versucht, zusätzliche Details zum Teilindex hinzuzufügen, z WHERE test_field is not null OR identifier is not null. Beim Einfügen erhalte ich jedoch die Einschränkungsfehlermeldung.

Shaun McCready
quelle

Antworten:

14

ON CONFLICT DO UPDATEVerhalten klären

Betrachten Sie das Handbuch hier :

Für jede einzelne Zeile, die zum Einfügen vorgeschlagen wird, wird entweder die Einfügung fortgesetzt, oder wenn eine durch angegebene Arbiter-Einschränkung oder ein durch angegebener Index conflict_targetverletzt wird, wird die Alternative gewählt conflict_action.

Meine kühne Betonung. Sie müssen also keine Prädikate für Spalten wiederholen, die im eindeutigen Index in der WHEREKlausel zu UPDATE(the conflict_action) enthalten sind:

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'

Die eindeutige Verletzung legt bereits fest, was Ihre hinzugefügte WHEREKlausel redundant durchsetzen würde.

Teilindex klären

Fügen Sie eine WHEREKlausel es ein tatsächlicher zu machen Teilindex wie Sie selbst erwähnt (aber mit umgekehrter Logik):

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

Zur Verwendung dieses Teilindex in Ihrem UPSERT benötigen Sie eine passende wie @ypercube demonstriert :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Nun wird auf den obigen Teilindex geschlossen. Jedoch , wie das Handbuch auch Notizen :

[...] Ein nicht partieller eindeutiger Index (ein eindeutiger Index ohne Prädikat) wird abgeleitet (und daher von diesem verwendet ON CONFLICT), wenn ein solcher Index verfügbar ist, der alle anderen Kriterien erfüllt.

Wenn Sie einen zusätzlichen (oder nur einen) Index (name, status)haben, wird dieser (auch) verwendet. Ein Index über (name, status, test_field)würde ausdrücklich nicht abgeleitet. Dies erklärt Ihr Problem nicht, hat aber möglicherweise die Verwirrung beim Testen vergrößert.

Lösung

AIUI, noch löst keines der oben genannten Probleme Ihr Problem . Mit dem Teilindex würden nur Sonderfälle mit übereinstimmenden NULL-Werten erfasst. Andere doppelte Zeilen werden entweder eingefügt, wenn Sie keine anderen übereinstimmenden eindeutigen Indizes / Einschränkungen haben, oder wenn Sie dies tun, wird eine Ausnahme ausgelöst. Ich nehme an, das ist nicht was du willst. Du schreibst:

Der zusammengesetzte Schlüssel besteht aus 20 Spalten, von denen 10 nullwertfähig sein können.

Was genau halten Sie für ein Duplikat? Postgres (gemäß dem SQL-Standard) betrachtet zwei NULL-Werte nicht als gleich. Das Handbuch:

Im Allgemeinen wird eine eindeutige Einschränkung verletzt, wenn die Tabelle mehr als eine Zeile enthält, in der die Werte aller in der Einschränkung enthaltenen Spalten gleich sind. Zwei Nullwerte werden in diesem Vergleich jedoch niemals als gleich angesehen. Dies bedeutet, dass es auch bei Vorliegen einer eindeutigen Einschränkung möglich ist, doppelte Zeilen, die einen Nullwert enthalten, in mindestens einer der eingeschränkten Spalten zu speichern. Dieses Verhalten entspricht dem SQL-Standard, es wurde jedoch festgestellt, dass andere SQL-Datenbanken dieser Regel möglicherweise nicht folgen. Seien Sie also vorsichtig, wenn Sie Anwendungen entwickeln, die portabel sein sollen.

Verbunden:

IchNULL gehe davon aus, dass Sie möchten, dassWerte in allen 10 nullbaren Spalten als gleich angesehen werden. Es ist elegant und praktisch, eine einzelne nullable-Spalte mit einem zusätzlichen Teilindex abzudecken, wie hier gezeigt:

Bei Spalten mit mehr Nullwerten ist dies jedoch schnell außer Kontrolle geraten. Sie benötigen einen Teilindex für jede eindeutige Kombination nullfähiger Spalten. Für nur 2 von denen , die für drei Teilindizes sind (a), (b)und (a,b). Die Zahl wächst exponentiell mit 2^n - 1. Um alle möglichen Kombinationen von NULL-Werten für Ihre 10 nullbaren Spalten abzudecken, benötigen Sie bereits 1023 Teilindizes. No Go.

Die einfache Lösung: Ersetzen Sie NULL-Werte und definieren Sie die beteiligten Spalten. NOT NULLMit einer einfachen UNIQUEEinschränkung würde alles gut funktionieren .

Wenn dies keine Option ist, schlage ich einen Ausdrucksindex vor, mit COALESCEdem NULL im Index ersetzt wird:

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

Die leere Zeichenfolge ( '') ist ein offensichtlicher Kandidat für Zeichentypen, aber Sie können jeden zulässigen Wert verwenden, der entweder nie erscheint oder mit NULL gemäß Ihrer Definition von "einzigartig" gefaltet werden kann .

Dann benutze diese Anweisung:

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

Wie bei @ypercube gehe ich davon aus, dass Sie countdie vorhandene Anzahl tatsächlich erweitern möchten . Da die Spalte NULL sein kann, würde das Hinzufügen von NULL die Spalte NULL setzen. Wenn Sie definieren count NOT NULL, können Sie vereinfachen.


Eine andere Idee wäre, einfach das conflict_target aus der Anweisung zu entfernen, um alle eindeutigen Verstöße abzudecken . Dann könnten Sie verschiedene eindeutige Indizes definieren, um eine differenziertere Definition dessen zu erhalten, was "eindeutig" sein soll. Aber das fliegt nicht mit ON CONFLICT DO UPDATE. Das Handbuch noch einmal:

Für ON CONFLICT DO NOTHINGist es optional, ein conflict_target anzugeben. Wenn dies weggelassen wird, werden Konflikte mit allen verwendbaren Einschränkungen (und eindeutigen Indizes) behandelt. Für ON CONFLICT DO UPDATEeine conflict_target müssen zur Verfügung gestellt werden.

Erwin Brandstetter
quelle
1
Nett. Ich habe die 20-10 Spalten übersprungen, als ich die Frage zum ersten Mal gelesen habe, und hatte keine Zeit, sie später zu vervollständigen. Das count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDkann vereinfacht werdencount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ
Noch einmal, meine "vereinfachte" Version ist nicht so selbstdokumentierend.
Ypercubeᵀᴹ
@ ypercubeᵀᴹ: Ich habe dein vorgeschlagenes Update angewendet. Es ist einfacher, danke.
Erwin Brandstetter
@ErwinBrandstetter Sie sind die besten
Seamus Abshere
7

Ich denke, das Problem ist, dass Sie keinen Teilindex haben und die ON CONFLICTSyntax nicht mit dem test_upsert_upsert_id_idxIndex, sondern mit der anderen eindeutigen Einschränkung übereinstimmt .

Wenn Sie den Index als partiell (mit WHERE test_field IS NULL) definieren:

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 ;

und diese Zeilen bereits in der Tabelle:

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

dann ist die Abfrage erfolgreich:

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

mit folgenden Ergebnissen:

('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ᵀᴹ
quelle
Dies verdeutlicht, wie ein Teilindex verwendet wird. Aber (glaube ich) es löst das Problem noch nicht.
Erwin Brandstetter
Sollte die Zählung für 'Maria' nicht bei 1 bleiben, da keine Aktualisierung erfolgt?
mpprdev
@mpprdev ja, du hast recht.
Ypercubeᵀᴹ