CHECK (harga >= 0), UNIQUE, foreign key — constraint di skema adalah janji bahwa data kotor tidak akan pernah masuk. Tetapi janji yang tidak pernah diuji hanyalah rumor: batas bawah yang salah tulis (> 0 alih-alih >= 0) membuat barang gratis tidak bisa disimpan, dan foreign key yang lupa dipasang membiarkan pesanan menunjuk barang yang tidak ada. Chapter ini membuktikan setiap constraint dengan tiga jenis uji: uji positif (data sah diterima), uji negatif (data tidak sah ditolak), dan uji batas (nilai tepat di tepi rentang sah). Termasuk satu pertanyaan yang sering muncul saat pemuatan massal: apakah COPY benar-benar menjalankan constraint yang sama?
Menyiapkan Tabel dengan Semua Jenis Constraint #
Fixture berikut menumpuk lima jenis constraint dalam dua tabel: PRIMARY KEY (implisit unique dan not-null), UNIQUE, NOT NULL, CHECK dengan rentang bawah dan atas, serta FOREIGN KEY. Rentang harga yang diuji adalah 0 sampai 10000000 — dua tepi yang akan digoncang dari kedua sisi:
-- Fixture pengujian integritas: satu tabel memuat semua jenis constraint
-- yang akan diuji: PRIMARY KEY, UNIQUE, NOT NULL, CHECK, dan FOREIGN KEY.
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL UNIQUE
);
CREATE TABLE barang (
kode text PRIMARY KEY,
nama text NOT NULL,
harga integer NOT NULL CHECK (harga >= 0 AND harga <= 10000000),
kategori_id integer NOT NULL REFERENCES kategori (id)
);
-- Nilai batas constraint CHECK harga: 0 dan 10000000 sama-sama sah.
INSERT INTO kategori VALUES
(1, 'makanan'),
(2, 'minuman'),
(3, 'aksesoris');Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 3Uji Positif: Data Sah Harus Diterima #
Uji positif memasukkan data yang sah — termasuk kedua nilai batas — lalu memverifikasi bahwa semuanya tersimpan. Jenis uji ini menangkap kesalahan “constraint terlalu ketat”: CHECK (harga > 0) yang menolak harga 0, atau unique yang ternyata sensitif huruf besar-kecil padahal tidak diinginkan:
-- Uji positif: data sah (termasuk nilai batas) harus DITERIMA.
-- Sebuah constraint yang salah tulis bisa menolak data yang seharusnya sah.
INSERT INTO barang (kode, nama, harga, kategori_id) VALUES
('BRG-001', 'Roti Tawar', 15000, 1),
('BRG-002', 'Kantong Belanja', 0, 3), -- batas bawah CHECK
('BRG-003', 'Mesin Kopi', 10000000, 3), -- batas atas CHECK
('BRG-004', 'Teh Kotak', 6000, 2);
SELECT CASE
WHEN (SELECT count(*) FROM barang) = 4
THEN 'LULUS: 4 baris sah tersimpan, termasuk batas bawah 0 dan batas atas 10000000'
ELSE 'GAGAL: ekspektasi 4, aktual ' || (SELECT count(*) FROM barang)::text
END AS uji_positif;Output yang Dihasilkan:
INSERT 0 4
uji_positif
------------------------------------------------------------------------------
LULUS: 4 baris sah tersimpan, termasuk batas bawah 0 dan batas atas 10000000
(1 row)Dua baris pertama dan keempat adalah data normal. Baris Kantong Belanja dengan harga 0 menguji tepi bawah, dan Mesin Kopi dengan harga 10000000 menguji tepi atas — keduanya harus diterima karena rentang CHECK bersifat inklusif (>= dan <=).
Uji Negatif Mentah: Error yang Diharapkan #
Bentuk paling sederhana dari uji negatif adalah file SQL yang berisikan satu perintah yang harus gagal:
-- Uji negatif bentuk paling sederhana: satu INSERT yang harus DITOLAK.
-- File ini sengaja berakhir dengan error — psql keluar dengan kode 3.
INSERT INTO barang (kode, nama, harga, kategori_id)
VALUES ('BRG-X1', 'Barang Uji', -1, 1);Output yang Dihasilkan:
psql:uji-negatif.sql:4: ERROR: new row for relation "barang" violates check constraint "barang_harga_check"
DETAIL: Failing row contains (BRG-X1, Barang Uji, -1, 1).Karena psql dijalankan dengan ON_ERROR_STOP=1, error ini menghentikan file dan membuat psql keluar dengan kode 3 — kode khusus “error SQL” yang membedakannya dari kegagal koneksi (kode 2). Pada pipeline integrasi berkelanjutan, kode keluar inilah yang dibaca mesin: exit 3 pada file yang memang menguji penolakan berarti lulus. Catatan pentingnya: error check_violation selalu membawa baris DETAIL berisi seluruh isi baris yang ditolak — informasi pertama yang Anda baca saat memburu data apa yang mencoba masuk.
Matriks Uji dengan DO Block #
Satu file exit 3 hanya menampung satu error — ON_ERROR_STOP menghentikan eksekusi di kegagalan pertama. Untuk menguji banyak kasus penolakan sekaligus, pola yang lebih kuat adalah menangkap error di dalam DO block: tiap INSERT dibungkus BEGIN ... EXCEPTION, kode SQLSTATE-nya direkam, lalu dibandingkan dengan harapan. File berikut menguji lima kasus dalam satu eksekusi:
-- Matriks uji negatif: banyak kasus penolakan dalam SATU file.
-- DO block menangkap error tiap INSERT, mencatat SQLSTATE-nya,
-- lalu membandingkan dengan SQLSTATE yang diantisipasi.
CREATE TABLE hasil_uji (
kasus text,
sqlstate_aktual text,
sqlstate_harapan text
);
DO $$
DECLARE
v_kasus text;
v_kode text;
BEGIN
FOREACH v_kasus IN ARRAY ARRAY[
'harga di bawah batas (-1)',
'harga di atas batas (10000001)',
'nama NULL',
'kode duplikat',
'kategori tidak ada (FK yatim)'
] LOOP
BEGIN
IF v_kasus = 'harga di bawah batas (-1)' THEN
INSERT INTO barang VALUES ('UJI-01', 'Uji A', -1, 1);
ELSIF v_kasus = 'harga di atas batas (10000001)' THEN
INSERT INTO barang VALUES ('UJI-02', 'Uji B', 10000001, 1);
ELSIF v_kasus = 'nama NULL' THEN
INSERT INTO barang VALUES ('UJI-03', NULL, 5000, 1);
ELSIF v_kasus = 'kode duplikat' THEN
INSERT INTO barang VALUES ('BRG-001', 'Duplikat', 5000, 1);
ELSE
INSERT INTO barang VALUES ('UJI-04', 'Uji C', 5000, 99);
END IF;
-- Baris ini hanya tercapai bila INSERT lolos — itu kegagalan uji.
INSERT INTO hasil_uji VALUES (v_kasus, 'TIDAK DITOLAK', '23514');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_kode = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (v_kasus, v_kode, NULL);
END;
END LOOP;
END $$;
UPDATE hasil_uji SET sqlstate_harapan = CASE
WHEN kasus LIKE 'harga%' THEN '23514' -- check_violation
WHEN kasus = 'nama NULL' THEN '23502' -- not_null_violation
WHEN kasus = 'kode duplikat' THEN '23505' -- unique_violation
ELSE '23503' -- foreign_key_violation
END;
SELECT kasus,
sqlstate_aktual,
CASE WHEN sqlstate_aktual = sqlstate_harapan
THEN 'LULUS'
ELSE 'GAGAL: harapan ' || sqlstate_harapan || ', aktual ' || sqlstate_aktual
END AS status
FROM hasil_uji
ORDER BY kasus;
-- Bukti akhir: tidak ada satu pun baris uji yang bocor masuk.
SELECT CASE WHEN (SELECT count(*) FROM barang WHERE kode LIKE 'UJI-%') = 0
THEN 'LULUS: tidak ada baris tidak sah yang tersimpan'
ELSE 'GAGAL: ada baris tidak sah yang tersimpan'
END AS uji_kebocoran;Output yang Dihasilkan:
CREATE TABLE
DO
UPDATE 5
kasus | sqlstate_aktual | status
--------------------------------+-----------------+--------
harga di atas batas (10000001) | 23514 | LULUS
harga di bawah batas (-1) | 23514 | LULUS
kategori tidak ada (FK yatim) | 23503 | LULUS
kode duplikat | 23505 | LULUS
nama NULL | 23502 | LULUS
(5 rows)
uji_kebocoran
-------------------------------------------------
LULUS: tidak ada baris tidak sah yang tersimpan
(1 row)Matriks lengkap yang diuji file tersebut:
| Constraint | Kasus tidak sah | SQLSTATE harapan |
|---|---|---|
CHECK | harga -1 (di bawah batas) | 23514 check_violation |
CHECK | harga 10000001 (di atas batas) | 23514 check_violation |
NOT NULL | kolom nama kosong | 23502 not_null_violation |
PRIMARY KEY | kode duplikat BRG-001 | 23505 unique_violation |
FOREIGN KEY | kategori_id 99 tidak ada | 23503 foreign_key_violation |
Bedah mekanismenya:
- Cabang
IFper kasus — setiap kasus menjalankan INSERT penyerangannya sendiri. Bila INSERT ternyata lolos, baris hasil dicatat sebagai'TIDAK DITOLAK'yang otomatis gagal saat dibandingkan — constraint yang bocor tertangkap. EXCEPTION WHEN OTHERS+GET STACKED DIAGNOSTICS— menangkap error apa pun dan mengambilRETURNED_SQLSTATE. Pembandingan dengan harapan tetap eksplisit di luar DO block, sehingga membaca file uji seperti membaca spesifikasi.- Asersi kebocoran terakhir —
count(*) WHERE kode LIKE 'UJI-%'memastikan tidak ada baris uji yang tersimpan. Constraint yang menolak dengan error tetap bisa saja “menolak” sambil menyimpan setengah baris — asersi ini menutup celah tersebut.
Pola DO block ini menjadi favorit karena laporan lengkap terlihat dalam satu output, tanpa perlu lima file terpisah. Teknik penangkapan errornya sendiri sudah dibahas tuntas di chapter Exception Handling .
Constraint Tetap Berlaku Saat COPY #
Mitos yang beredar: “batch import bisa melewati constraint”. Faktanya, COPY menjalankan pemeriksaan yang persis sama dengan INSERT — dan lebih dari itu, COPY bersifat atomik: satu baris buruk menggulung balik seluruh batch, bukan hanya barisnya. Skrip berikut membuktikan keduanya dengan dua batch:
#!/usr/bin/env bash
# Pengujian integritas saat pemuatan massal: COPY menjalankan constraint
# yang sama seperti INSERT, dan satu baris buruk menggulung balik seluruh batch.
echo '==> batch 1: tiga baris sah lewat COPY'
psql -X -q <<'SQL' 2>&1
COPY barang (kode, nama, harga, kategori_id) FROM STDIN WITH (FORMAT csv);
BRG-006,Gula Pasir,14000,2
BRG-007,Teh Celup,8000,2
BRG-008,Susu Kental,18000,2
\.
SELECT count(*) AS jumlah_barang FROM barang;
SQL
echo '==> batch 2: satu baris harga negatif tersembunyi di antara baris sah'
if printf 'BRG-009,Kopi Bubuk,25000,2\nBRG-010,Keju Slice,-45000,2\nBRG-011,Madu Hutan,32000,2\n\\.\n' \
| psql -X -c "COPY barang (kode, nama, harga, kategori_id) FROM STDIN WITH (FORMAT csv)" 2>&1; then
echo 'batch 2 DITERIMA (tidak sesuai harapan)'
else
echo 'batch 2 DITOLAK'
fi
psql -X -t -A -c "SELECT 'jumlah barang tetap: ' || count(*) || ' — tidak ada baris batch 2 yang bocor masuk' AS kesimpulan FROM barang;"Output yang Dihasilkan:
==> batch 1: tiga baris sah lewat COPY
jumlah_barang
---------------
7
(1 row)
==> batch 2: satu baris harga negatif tersembunyi di antara baris sah
ERROR: new row for relation "barang" violates check constraint "barang_harga_check"
DETAIL: Failing row contains (BRG-010, Keju Slice, -45000, 2).
CONTEXT: COPY barang, line 2: "BRG-010,Keju Slice,-45000,2"
batch 2 DITOLAK
jumlah barang tetap: 7 — tidak ada baris batch 2 yang bocor masukBatch pertama memuat tiga baris sah — jumlah barang naik dari 4 ke 7. Batch kedua menyembunyikan satu harga negatif (Keju Slice) di antara dua baris sah: error check_violation muncul dengan CONTEXT yang menyebut baris CSV penyebabnya, dan jumlah barang tetap 7 — bukan 8 atau 9. Tidak ada baris yang bocor masuk; pemuatan massal tidak bisa dipakai sebagai pintu belakang untuk data kotor.
Audit Integritas Sebelum Memasang FK #
Skenario paling umum di sistem warisan: tabel lama dibuat tanpa foreign key, bertahun-tahun menampung data, dan kini berisi pesanan yang menunjuk barang tidak dikenal (orphan). Memasang FK langsung pada data seperti itu akan gagal — PostgreSQL menolak membuat constraint yang tidak terpenuhi. Urutan yang benar: audit dulu, bersihkan, baru pasang:
-- Skenario warisan sistem lama: tabel pesanan_lama dibuat TANPA foreign key
-- dan sudah berisi data termasuk kode barang yang tidak ada di tabel barang.
CREATE TABLE pesanan_lama (
id integer PRIMARY KEY,
kode_barang text NOT NULL,
jumlah integer NOT NULL
);
INSERT INTO pesanan_lama VALUES
(1, 'BRG-001', 2),
(2, 'BRG-777', 1), -- yatim: kode tidak ada di tabel barang
(3, 'BRG-004', 5);
-- Audit integritas referensial: cari pesanan yang menunjuk barang tidak dikenal.
SELECT p.id, p.kode_barang, p.jumlah
FROM pesanan_lama p
LEFT JOIN barang b ON b.kode = p.kode_barang
WHERE b.kode IS NULL
ORDER BY p.id;
-- Bersihkan yatimnya, baru pasang foreign key.
DELETE FROM pesanan_lama
WHERE NOT EXISTS (SELECT 1 FROM barang b WHERE b.kode = pesanan_lama.kode_barang);
ALTER TABLE pesanan_lama
ADD CONSTRAINT pesanan_lama_kode_barang_fkey
FOREIGN KEY (kode_barang) REFERENCES barang (kode);
SELECT CASE
WHEN EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'pesanan_lama_kode_barang_fkey')
THEN 'LULUS: foreign key terpasang setelah data dibersihkan'
ELSE 'GAGAL: constraint tidak ditemukan'
END AS uji_pemasangan;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | kode_barang | jumlah
----+-------------+--------
2 | BRG-777 | 1
(1 row)
DELETE 1
ALTER TABLE
uji_pemasangan
-------------------------------------------------------
LULUS: foreign key terpasang setelah data dibersihkan
(1 row)Query auditnya adalah LEFT JOIN + IS NULL: pesanan yang setelah di-join ke barang tetap tidak menemukan pasangan adalah yatim. Versi NOT EXISTS memberikan hasil sama dan sering lebih cepat untuk tabel besar. Setelah satu baris yatim dihapus, ALTER TABLE ... ADD CONSTRAINT berhasil — dan mulai saat itu, constraint yang baru terpasang ikut dijaga oleh semua uji negatif di atas.
Data yang bersih di satu momen belum menjamin bersih di momen berikutnya — apalagi bila banyak sesi menulis bersamaan. Transaksi yang menggulung balik, savepoint yang menyelamatkan sebagian pekerjaan, dan constraint yang ditunda pemeriksaannya hingga COMMIT adalah lapisan berikutnya yang layak diuji. Chapter berikutnya menguji semuanya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
audit-orphan.sql
Unduh berkas-- Skenario warisan sistem lama: tabel pesanan_lama dibuat TANPA foreign key
-- dan sudah berisi data termasuk kode barang yang tidak ada di tabel barang.
CREATE TABLE pesanan_lama (
id integer PRIMARY KEY,
kode_barang text NOT NULL,
jumlah integer NOT NULL
);
INSERT INTO pesanan_lama VALUES
(1, 'BRG-001', 2),
(2, 'BRG-777', 1), -- yatim: kode tidak ada di tabel barang
(3, 'BRG-004', 5);
-- Audit integritas referensial: cari pesanan yang menunjuk barang tidak dikenal.
SELECT p.id, p.kode_barang, p.jumlah
FROM pesanan_lama p
LEFT JOIN barang b ON b.kode = p.kode_barang
WHERE b.kode IS NULL
ORDER BY p.id;
-- Bersihkan yatimnya, baru pasang foreign key.
DELETE FROM pesanan_lama
WHERE NOT EXISTS (SELECT 1 FROM barang b WHERE b.kode = pesanan_lama.kode_barang);
ALTER TABLE pesanan_lama
ADD CONSTRAINT pesanan_lama_kode_barang_fkey
FOREIGN KEY (kode_barang) REFERENCES barang (kode);
SELECT CASE
WHEN EXISTS (SELECT 1 FROM pg_constraint
WHERE conname = 'pesanan_lama_kode_barang_fkey')
THEN 'LULUS: foreign key terpasang setelah data dibersihkan'
ELSE 'GAGAL: constraint tidak ditemukan'
END AS uji_pemasangan;
buat-tabel-uji.sql
Unduh berkas-- Fixture pengujian integritas: satu tabel memuat semua jenis constraint
-- yang akan diuji: PRIMARY KEY, UNIQUE, NOT NULL, CHECK, dan FOREIGN KEY.
CREATE TABLE kategori (
id integer PRIMARY KEY,
nama text NOT NULL UNIQUE
);
CREATE TABLE barang (
kode text PRIMARY KEY,
nama text NOT NULL,
harga integer NOT NULL CHECK (harga >= 0 AND harga <= 10000000),
kategori_id integer NOT NULL REFERENCES kategori (id)
);
-- Nilai batas constraint CHECK harga: 0 dan 10000000 sama-sama sah.
INSERT INTO kategori VALUES
(1, 'makanan'),
(2, 'minuman'),
(3, 'aksesoris');
uji-copy-atomik.sh
Unduh berkas#!/usr/bin/env bash
# Pengujian integritas saat pemuatan massal: COPY menjalankan constraint
# yang sama seperti INSERT, dan satu baris buruk menggulung balik seluruh batch.
echo '==> batch 1: tiga baris sah lewat COPY'
psql -X -q <<'SQL' 2>&1
COPY barang (kode, nama, harga, kategori_id) FROM STDIN WITH (FORMAT csv);
BRG-006,Gula Pasir,14000,2
BRG-007,Teh Celup,8000,2
BRG-008,Susu Kental,18000,2
\.
SELECT count(*) AS jumlah_barang FROM barang;
SQL
echo '==> batch 2: satu baris harga negatif tersembunyi di antara baris sah'
if printf 'BRG-009,Kopi Bubuk,25000,2\nBRG-010,Keju Slice,-45000,2\nBRG-011,Madu Hutan,32000,2\n\\.\n' \
| psql -X -c "COPY barang (kode, nama, harga, kategori_id) FROM STDIN WITH (FORMAT csv)" 2>&1; then
echo 'batch 2 DITERIMA (tidak sesuai harapan)'
else
echo 'batch 2 DITOLAK'
fi
psql -X -t -A -c "SELECT 'jumlah barang tetap: ' || count(*) || ' — tidak ada baris batch 2 yang bocor masuk' AS kesimpulan FROM barang;"
uji-matriks.sql
Unduh berkas-- Matriks uji negatif: banyak kasus penolakan dalam SATU file.
-- DO block menangkap error tiap INSERT, mencatat SQLSTATE-nya,
-- lalu membandingkan dengan SQLSTATE yang diantisipasi.
CREATE TABLE hasil_uji (
kasus text,
sqlstate_aktual text,
sqlstate_harapan text
);
DO $$
DECLARE
v_kasus text;
v_kode text;
BEGIN
FOREACH v_kasus IN ARRAY ARRAY[
'harga di bawah batas (-1)',
'harga di atas batas (10000001)',
'nama NULL',
'kode duplikat',
'kategori tidak ada (FK yatim)'
] LOOP
BEGIN
IF v_kasus = 'harga di bawah batas (-1)' THEN
INSERT INTO barang VALUES ('UJI-01', 'Uji A', -1, 1);
ELSIF v_kasus = 'harga di atas batas (10000001)' THEN
INSERT INTO barang VALUES ('UJI-02', 'Uji B', 10000001, 1);
ELSIF v_kasus = 'nama NULL' THEN
INSERT INTO barang VALUES ('UJI-03', NULL, 5000, 1);
ELSIF v_kasus = 'kode duplikat' THEN
INSERT INTO barang VALUES ('BRG-001', 'Duplikat', 5000, 1);
ELSE
INSERT INTO barang VALUES ('UJI-04', 'Uji C', 5000, 99);
END IF;
-- Baris ini hanya tercapai bila INSERT lolos — itu kegagalan uji.
INSERT INTO hasil_uji VALUES (v_kasus, 'TIDAK DITOLAK', '23514');
EXCEPTION WHEN OTHERS THEN
GET STACKED DIAGNOSTICS v_kode = RETURNED_SQLSTATE;
INSERT INTO hasil_uji VALUES (v_kasus, v_kode, NULL);
END;
END LOOP;
END $$;
UPDATE hasil_uji SET sqlstate_harapan = CASE
WHEN kasus LIKE 'harga%' THEN '23514' -- check_violation
WHEN kasus = 'nama NULL' THEN '23502' -- not_null_violation
WHEN kasus = 'kode duplikat' THEN '23505' -- unique_violation
ELSE '23503' -- foreign_key_violation
END;
SELECT kasus,
sqlstate_aktual,
CASE WHEN sqlstate_aktual = sqlstate_harapan
THEN 'LULUS'
ELSE 'GAGAL: harapan ' || sqlstate_harapan || ', aktual ' || sqlstate_aktual
END AS status
FROM hasil_uji
ORDER BY kasus;
-- Bukti akhir: tidak ada satu pun baris uji yang bocor masuk.
SELECT CASE WHEN (SELECT count(*) FROM barang WHERE kode LIKE 'UJI-%') = 0
THEN 'LULUS: tidak ada baris tidak sah yang tersimpan'
ELSE 'GAGAL: ada baris tidak sah yang tersimpan'
END AS uji_kebocoran;
uji-negatif.sql
Unduh berkas-- Uji negatif bentuk paling sederhana: satu INSERT yang harus DITOLAK.
-- File ini sengaja berakhir dengan error — psql keluar dengan kode 3.
INSERT INTO barang (kode, nama, harga, kategori_id)
VALUES ('BRG-X1', 'Barang Uji', -1, 1);
uji-positif.sql
Unduh berkas-- Uji positif: data sah (termasuk nilai batas) harus DITERIMA.
-- Sebuah constraint yang salah tulis bisa menolak data yang seharusnya sah.
INSERT INTO barang (kode, nama, harga, kategori_id) VALUES
('BRG-001', 'Roti Tawar', 15000, 1),
('BRG-002', 'Kantong Belanja', 0, 3), -- batas bawah CHECK
('BRG-003', 'Mesin Kopi', 10000000, 3), -- batas atas CHECK
('BRG-004', 'Teh Kotak', 6000, 2);
SELECT CASE
WHEN (SELECT count(*) FROM barang) = 4
THEN 'LULUS: 4 baris sah tersimpan, termasuk batas bawah 0 dan batas atas 10000000'
ELSE 'GAGAL: ekspektasi 4, aktual ' || (SELECT count(*) FROM barang)::text
END AS uji_positif;