Masalah UPSERT PostgreSQL dengan nilai NULL

13

Saya mengalami masalah dengan menggunakan fitur UPSERT baru di Postgres 9.5

Saya punya tabel yang digunakan untuk mengumpulkan data dari tabel lain. Kunci komposit terdiri dari 20 kolom, 10 di antaranya dapat nullable. Di bawah ini saya telah membuat versi lebih kecil dari masalah yang saya alami, khususnya dengan nilai 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)
);

Menjalankan kueri ini berfungsi sesuai kebutuhan (Sisipan pertama, kemudian sisipan berikutnya cukup menambah jumlah):

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

Namun jika saya menjalankan kueri ini, 1 baris disisipkan setiap kali daripada menambah jumlah untuk baris awal:

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;

Ini masalah saya. Saya hanya perlu menambah nilai hitungan dan tidak membuat beberapa baris identik dengan nilai nol.

Mencoba menambahkan indeks unik parsial:

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

Namun, ini menghasilkan hasil yang sama, baik beberapa baris nol yang dimasukkan atau pesan kesalahan ini ketika mencoba untuk memasukkan:

GALAT: tidak ada kendala unik atau pengecualian yang cocok dengan spesifikasi ON CONFLICT

Saya sudah mencoba menambahkan rincian tambahan pada indeks parsial seperti WHERE test_field is not null OR identifier is not null. Namun, saat memasukkan saya mendapatkan pesan galat kendala.

Shaun McCready
sumber

Jawaban:

14

Memperjelas ON CONFLICT DO UPDATEperilaku

Pertimbangkan manual di sini :

Untuk setiap baris yang diusulkan untuk disisipkan, baik hasil penyisipan, atau, jika batasan arbiter atau indeks yang ditentukan oleh conflict_targetdilanggar, alternatif conflict_actiondiambil.

Penekanan berani saya. Jadi Anda tidak perlu mengulangi predikat untuk kolom termasuk dalam indeks yang unik dalam WHEREklausul ke UPDATE(yang 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'

Pelanggaran unik sudah menetapkan apa yang WHEREakan diterapkan klausa tambahan Anda secara berlebihan.

Jelaskan indeks parsial

Tambahkan WHEREklausa untuk menjadikannya sebagai indeks parsial aktual seperti yang Anda sebutkan sendiri (tetapi dengan logika terbalik):

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

Untuk menggunakan indeks parsial ini di UPSERT Anda, Anda perlu pencocokan seperti @ypercube menunjukkan :conflict_target

ON CONFLICT (name, status) WHERE test_field IS NULL

Sekarang indeks parsial di atas disimpulkan. Namun , sebagaimana manual juga mencatat :

[...] indeks unik non-parsial (indeks unik tanpa predikat) akan disimpulkan (dan karenanya digunakan oleh ON CONFLICT) jika indeks tersebut memenuhi setiap kriteria lain tersedia.

Jika Anda memiliki indeks tambahan (atau hanya) hanya (name, status)itu akan (juga) digunakan. Indeks aktif tidak akan disimpulkan (name, status, test_field)secara eksplisit . Ini tidak menjelaskan masalah Anda, tetapi mungkin menambah kebingungan saat pengujian.

Larutan

AIUI, belum ada yang menyelesaikan masalah Anda di atas . Dengan indeks parsial, hanya kasus khusus dengan nilai NULL yang cocok yang akan ditangkap. Dan baris duplikat lainnya akan dimasukkan jika Anda tidak memiliki indeks / kendala unik yang cocok lainnya, atau meningkatkan pengecualian jika Anda melakukannya. Saya kira itu bukan yang Anda inginkan. Anda menulis:

Kunci komposit terdiri dari 20 kolom, 10 di antaranya dapat nullable.

Apa tepatnya yang Anda anggap duplikat? Postgres (sesuai dengan standar SQL) tidak menganggap dua nilai NULL sama. Manual:

Secara umum, batasan unik dilanggar jika ada lebih dari satu baris dalam tabel di mana nilai semua kolom yang termasuk dalam kendala sama. Namun, dua nilai nol tidak pernah dianggap sama dalam perbandingan ini. Itu berarti bahkan dengan adanya batasan unik, dimungkinkan untuk menyimpan baris duplikat yang berisi nilai nol di setidaknya salah satu kolom yang dibatasi. Perilaku ini sesuai dengan standar SQL, tetapi kami telah mendengar bahwa database SQL lain mungkin tidak mengikuti aturan ini. Jadi berhati-hatilah saat mengembangkan aplikasi yang dimaksudkan untuk portable.

Terkait:

Saya berasumsi Anda inginNULLnilai di semua 10 kolom nullable dianggap sama. Elegan & praktis untuk menutupi satu kolom yang dapat dibatalkan dengan indeks parsial tambahan seperti yang diperlihatkan di sini:

Tapi ini keluar dari tangan dengan cepat untuk kolom yang lebih dapat dibatalkan. Anda memerlukan indeks parsial untuk setiap kombinasi kolom nullable yang berbeda. Untuk hanya 2 dari yang 3 indeks parsial untuk (a), (b)dan (a,b). Jumlah ini meningkat secara eksponensial 2^n - 1. Untuk 10 kolom yang tidak dapat dibatalkan, untuk mencakup semua kemungkinan kombinasi nilai NULL, Anda sudah membutuhkan 1023 indeks parsial. Tidak pergi.

Solusi sederhana: ganti nilai NULL dan tentukan kolom yang terlibat NOT NULL, dan semuanya akan bekerja dengan baik dengan UNIQUEkendala sederhana .

Jika itu bukan opsi, saya sarankan indeks ekspresi dengan COALESCEuntuk mengganti NULL dalam indeks:

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

String kosong ( '') adalah calon yang jelas untuk jenis karakter, tetapi Anda dapat menggunakan setiap nilai hukum yang baik tidak pernah muncul atau dapat dilipat dengan NULL menurut Anda definisi "unik".

Kemudian gunakan pernyataan ini:

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

Seperti @ypercube, saya berasumsi Anda benar-benar ingin menambah countjumlah yang ada. Karena kolom bisa NULL, menambahkan NULL akan mengatur kolom NULL. Jika Anda mendefinisikan count NOT NULL, Anda dapat menyederhanakan.


Gagasan lain adalah dengan hanya men-drop konflik_target dari pernyataan untuk mencakup semua pelanggaran unik . Kemudian Anda dapat menentukan berbagai indeks unik untuk definisi yang lebih canggih tentang apa yang seharusnya "unik". Tapi itu tidak akan terbang ON CONFLICT DO UPDATE. Manual sekali lagi:

Karena ON CONFLICT DO NOTHING, opsional untuk menentukan konflik_target; ketika dihilangkan, konflik dengan semua batasan yang dapat digunakan (dan indeks unik) ditangani. Sebab ON CONFLICT DO UPDATE, konflik_target harus disediakan.

Erwin Brandstetter
sumber
1
Bagus. Saya melewatkan bagian 20-10 kolom saat pertama kali saya membaca pertanyaan dan tidak punya waktu untuk menyelesaikannya nanti. The count = CASE WHEN EXCLUDED.count IS NULL THEN tu.count ELSE COALESCE(tu.count, 0) + COALESCE(EXCLUDED.count, 0) ENDdapat disederhanakancount = COALESCE(tu.count+EXCLUDED.count, EXCLUDED.count, tu.count)
ypercubeᵀᴹ
Melihat lagi, versi "disederhanakan" saya tidak mendokumentasikan diri sendiri.
ypercubeᵀᴹ
@ ypercubeᵀᴹ: Saya menerapkan pembaruan yang Anda sarankan. Lebih sederhana, terima kasih.
Erwin Brandstetter
@ErwinBrandstetter Anda yang terbaik
Seamus Abshere
7

Saya pikir masalahnya adalah bahwa Anda tidak memiliki indeks parsial dan ON CONFLICTsintaks tidak cocok dengan test_upsert_upsert_id_idxindeks tetapi kendala unik lainnya.

Jika Anda mendefinisikan indeks sebagai parsial (dengan 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 ;

dan baris-baris ini sudah ada di tabel:

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

maka kueri akan berhasil:

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

dengan hasil sebagai berikut:

('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ᵀᴹ
sumber
Ini menjelaskan cara menggunakan indeks parsial. Tapi (saya pikir) itu belum menyelesaikan masalah.
Erwin Brandstetter
bukankah seharusnya jumlah 'maria' tetap di 1 karena tidak ada pembaruan yang terjadi?
mpprdev
@mpprdev ya, Anda benar.
ypercubeᵀᴹ