NULL, dan kenapa filter-mu tidak mengembalikan apa pun
Logika tiga nilai, bug NOT IN yang diam-diam mengembalikan himpunan kosong, IS DISTINCT FROM versus <=>, dan bagaimana agregat memperlakukan NULL berbeda dari dugaanmu.
NULL bukan nol dan bukan string kosong. Dia berarti tidak diketahui. Begitu
kamu memegang satu kalimat itu, sebagian besar perilaku SQL yang mengejutkan jadi
bisa diturunkan, bukan dihafal.
Perbandingan mengembalikan tiga nilai, bukan dua
Setiap perbandingan yang melibatkan NULL menghasilkan NULL — bukan benar, bukan
salah:
SELECT 1 = NULL; -- NULLSELECT 1 <> NULL; -- NULLSELECT NULL = NULL; -- NULLDan WHERE menyimpan sebuah baris hanya ketika kondisinya benar. Bukan “tidak
salah” — benar. Jadi WHERE coupon_code = NULL tidak mengembalikan apa pun, tidak
pernah, di tabel mana pun. Itulah kenapa IS NULL ada sebagai sintaks terpisah.
Tabel logikanya mengikuti dari “tidak diketahui”:
AND |
benar | salah | NULL |
|---|---|---|---|
| benar | benar | salah | NULL |
| salah | salah | salah | salah |
| NULL | NULL | salah | NULL |
OR |
benar | salah | NULL |
|---|---|---|---|
| benar | benar | benar | benar |
| salah | benar | salah | NULL |
| NULL | benar | NULL | NULL |
salah AND NULL adalah salah karena satu yang salah sudah cukup. benar OR NULL
adalah benar dengan alasan yang sama. Di semua tempat lain, ketidaktahuan menular.
NOT NULL adalah NULL, dan detail itulah yang jadi isi bagian berikutnya.
Bug NOT IN
Ini kesalahan NULL yang paling mahal, karena hasilnya himpunan kosong, bukan error.
-- Customer yang belum pernah order. Kelihatan benar. Salah.SELECT * FROM customerWHERE id NOT IN (SELECT customer_id FROM "order");Kalau ada satu saja baris subquery itu yang bernilai NULL, seluruh query
mengembalikan nol baris. IN adalah rangkaian OR, jadi NOT IN adalah rangkaian
AND atas <>:
id <> 1 AND id <> 7 AND id <> NULL -- menjadi -- benar AND benar AND NULL = NULL --> baris ditolakSetiap baris ditolak, dan tidak ada yang memberitahumu. Di skema kita
order.customer_id itu NOT NULL, jadi query khusus ini aman hari ini — dan
berhenti aman di hari seseorang membuat kolom itu boleh NULL. Jangan bergantung
pada itu.
Tiga perbaikan, berurutan sesuai yang paling dianjurkan:
-- 1. NOT EXISTS: benar terlepas dari NULL, dan biasanya paling cepatSELECT * FROM customer cWHERE NOT EXISTS (SELECT 1 FROM "order" o WHERE o.customer_id = c.id);
-- 2. LEFT JOIN ... IS NULL (sebuah "anti-join"), lihat materi 4SELECT c.* FROM customer cLEFT JOIN "order" o ON o.customer_id = c.idWHERE o.id IS NULL;
-- 3. NOT IN dengan NULL disingkirkan — jalan, tapi kamu harus ingat alasannyaSELECT * FROM customerWHERE id NOT IN (SELECT customer_id FROM "order" WHERE customer_id IS NOT NULL);NOT EXISTS adalah kebiasaan yang pantas dibangun. Dia tidak punya jebakan NULL
untuk diingat, dan dia bisa berhenti di baris cocok pertama alih-alih membangun
daftar penuh.
Perhatikan ketidaksimetrisannya: IN biasa dengan NULL di dalam daftar tidak
rusak. Dia cuma tidak akan pernah cocok dengan NULL-nya, dan itu memang yang kamu
harapkan.
Membandingkan dua kolom yang boleh NULL
Kadang kamu benar-benar ingin “keduanya sama, dengan NULL dianggap sebuah nilai”. Kedua mesin menuliskannya berbeda:
| PostgreSQL | MySQL | |
|---|---|---|
| Sama, aman-NULL | a IS NOT DISTINCT FROM b |
a <=> b |
| Beda, aman-NULL | a IS DISTINCT FROM b |
NOT (a <=> b) |
-- PostgreSQL: baris yang kuponnya berubah, termasuk menjadi atau dari NULLWHERE new_coupon IS DISTINCT FROM old_coupon
-- MySQLWHERE NOT (new_coupon <=> old_coupon)Ini paling penting pada deteksi perubahan dan pada logika upsert. Menulis
WHERE new_coupon <> old_coupon di sana diam-diam mengabaikan setiap baris yang
salah satu sisinya NULL — padahal itu justru baris di mana sesuatu baru diisi atau
dikosongkan.
COALESCE dan NULLIF
COALESCE mengembalikan argumen pertamanya yang bukan NULL. Kedua mesin punya.
SELECT id, COALESCE(coupon_code, 'none') AS coupon FROM "order";MySQL juga punya IFNULL(a, b) untuk kasus dua argumen, dan PostgreSQL tidak.
Pakai COALESCE supaya query-nya berpindah mesin tanpa diubah.
NULLIF(a, b) mengembalikan NULL ketika a = b, dan itu alat untuk mengubah nilai
sentinel kembali jadi ketiadaan yang sebenarnya — sekaligus untuk menghindari
pembagian dengan nol:
SELECT SUM(unit_cents * quantity) / NULLIF(SUM(quantity), 0) AS avg_centsFROM order_item;Pembagian dengan NULL menghasilkan NULL. Pembagian dengan nol adalah error di
PostgreSQL dan, di MySQL pada mode ketat bawaan, juga error. NULLIF mengubah
kegagalan itu jadi NULL yang bisa kamu COALESCE.
CASE
CASE adalah kondisional milik SQL, dan dia berhenti di kecocokan pertama sesuai
urutan:
SELECT id, CASE WHEN shipped_at IS NOT NULL THEN 'shipped' WHEN status = 'paid' THEN 'awaiting dispatch' WHEN placed_at < now() - interval '7 days' THEN 'stale' ELSE 'new' END AS stateFROM "order";Tanpa ELSE, baris yang tidak cocok menghasilkan NULL — sumber umum NULL yang
tak terjelaskan di sebuah laporan. Tulis ELSE-nya bahkan ketika kamu yakin dia
tidak mungkin tercapai.
Di MySQL, now() - interval '7 days' ditulis NOW() - INTERVAL 7 DAY.
Agregat mengabaikan NULL, dan COUNT tempat kamu menyadarinya
Semua agregat kecuali COUNT(*) melewati input NULL.
SELECT COUNT(*) AS rows_total, -- menghitung baris COUNT(shipped_at) AS rows_shipped, -- menghitung nilai bukan-NULL AVG(unit_cents) AS avg_price -- mengabaikan NULL dalam rata-rataFROM "order" o JOIN order_item i ON i.order_id = o.id;COUNT(col) yang lebih kecil dari COUNT(*) bukan bug; itu definisinya. Dan AVG
yang melewati NULL berarti pembaginya adalah jumlah nilai yang diketahui — dan
itu biasanya benar, tapi salah kalau maksudmu menganggap yang tidak diketahui itu
nol. Kalau begitu, katakan: AVG(COALESCE(unit_cents, 0)).
Agregat atas nol baris juga pantas diketahui: SUM dari ketiadaan adalah NULL,
bukan 0. COUNT dari ketiadaan adalah 0. Jadi COALESCE(SUM(x), 0) adalah
bentuk aman di laporan yang mungkin tidak punya baris.
NULL dan UNIQUE
Constraint UNIQUE mengizinkan banyak NULL, di kedua mesin, karena dua hal yang
tidak diketahui tidak diketahui sama.
-- PostgreSQL maupun MySQL menerima semua iniINSERT INTO "order" (customer_id, status, placed_at, coupon_code)VALUES (1, 'new', now(), NULL), (1, 'new', now(), NULL);Itu perilaku standar dan biasanya memang yang kamu mau. Kalau bukan — kalau kamu
butuh “paling banyak satu baris tanpa kupon per customer” — jawabannya adalah
partial index di PostgreSQL (CREATE UNIQUE INDEX ... WHERE coupon_code IS NULL)
dan, di MySQL, sebuah generated column, karena MySQL tidak punya partial index.
Materi 8 membahasnya.
Yang perlu dibawa pulang
NULLberarti tidak diketahui;WHEREhanya menyimpan yang benar.NOT INatas kolom yang boleh NULL tidak mengembalikan apa pun. PakaiNOT EXISTS.- Kesamaan aman-NULL:
IS NOT DISTINCT FROMdi PostgreSQL,<=>di MySQL. - Utamakan
COALESCEdaripadaIFNULL; pakaiNULLIFsupaya pembagi aman. - Selalu tulis
ELSEdi dalamCASE. SUMatas nol baris adalah NULL, bukan nol.
Diskusi
Memuat komentar…