Relasi satu-ke-banyak adalah pola yang paling sering Anda temui di hampir setiap skema database: satu pelanggan membuat banyak pesanan, satu kategori menampung banyak produk, satu penulis menghasilkan banyak artikel. Dari sisi anak, relasinya terasa seperti “banyak-ke-satu” — banyak pesanan menunjuk satu pelanggan yang sama — dan dua nama itu sebenarnya menjelaskan relasi yang sama dari dua arah. Yang mengejutkan pendatang baru bukan cara membentuknya, melainkan arahnya: kolom rujukan hanya boleh hidup di satu sisi, dan salah memilih sisi berujung pada anti-pola paling terkenal di dunia desain database. Chapter ini membangun polanya di Toko Maju Jaya, membuktikan arah yang benar dengan data, lalu menghancurkan anti-pola lawannya sebagai peringatan.
Pola: Kolom Rujukan di Sisi Anak #
Relasi 1:N di PostgreSQL dibentuk dengan satu foreign key yang berdiri di tabel anak — tabel sisi “banyak”. Tabel induk tidak menyimpan apa pun tentang anak-anaknya:
-- Relasi one-to-many: kolom rujukan selalu di sisi anak (banyak).
-- Satu pelanggan punya banyak pesanan; setiap pesanan punya satu pelanggan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL,
pelanggan_id integer REFERENCES pelanggan (id)
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Andi Wijaya', 'Surabaya', '[email protected]'),
(4, 'Dewi Lestari', 'Medan', '[email protected]');
INSERT INTO pesanan (id, tanggal, total, pelanggan_id) VALUES
(101, DATE '2026-01-05', 150000.00, 1),
(102, DATE '2026-01-18', 75000.00, 1),
(103, DATE '2026-01-09', 220000.00, 2),
(104, DATE '2026-02-02', 50000.00, 4),
(105, DATE '2026-02-10', 30000.00, NULL);
-- Satu induk, banyak anak: Budi tampil di dua baris,
-- pesanan 105 (tunai kasir) tidak punya induk sama sekali.
SELECT ps.id, ps.tanggal, ps.total, p.nama
FROM pesanan ps
LEFT JOIN pelanggan p ON p.id = ps.pelanggan_id
ORDER BY ps.id;
-- Agregasi per induk, pelanggan tanpa pesanan tetap terhitung nol.
SELECT p.id, p.nama, COUNT(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 4
INSERT 0 5
id | tanggal | total | nama
-----+------------+-----------+--------------
101 | 2026-01-05 | 150000.00 | Budi Santoso
102 | 2026-01-18 | 75000.00 | Budi Santoso
103 | 2026-01-09 | 220000.00 | Siti Rahayu
104 | 2026-02-02 | 50000.00 | Dewi Lestari
105 | 2026-02-10 | 30000.00 |
(5 rows)
id | nama | jumlah_pesanan
----+--------------+----------------
1 | Budi Santoso | 2
2 | Siti Rahayu | 1
3 | Andi Wijaya | 0
4 | Dewi Lestari | 1
(4 rows)Tiga fakta dari hasil eksekusi file tersebut menjadi bukti arah relasi:
pelanggan_id integer REFERENCES pelanggan (id)ada di tabelpesanan— setiap baris pesanan menyimpan tepat satu nilai rujukan. Satu nilai per baris itulah yang membuat sisi anak otomatis berperan “banyak”: tidak ada yang melarang dua baris pesanan menyimpan nilai rujukan yang sama.- Budi Santoso tampil di dua baris hasil join — pesanan 101 dan 102 sama-sama menunjuk
pelanggan_id = 1. Relasi satu-ke-banyak terjadi tanpa satu pun kolom tambahan di tabelpelanggan. - Pesanan 105 tampil dengan kolom nama kosong — transaksi tunai kasir tanpa akun pelanggan. Kolom rujukan yang tidak diberi
NOT NULLmembuat relasi ini opsional di sisi anak; keputusan itu identik dengan keputusan yang kita ambil di section JOIN .
Query kedua memperlihatkan cara menghitung anak per induk: LEFT JOIN menjaga pelanggan tanpa pesanan tetap terhitung — Andi Wijaya tampil dengan nol, bukan hilang dari laporan. Pola LEFT JOIN + COUNT(kolom_anak) ini dibedah tuntas di chapter LEFT JOIN
.
Anti-Pola: Daftar ID di Sisi Induk #
Setiap kali desainer merasa “lebih praktis” menyimpan daftar anak di satu kolom induk, anti-pola ini lahir: daftar id disimpan sebagai teks berpisah koma. Mari kita bangun versi rusaknya dan biarkan kekacauannya terlihat:
-- Anti-pola: daftar id pesanan disimpan sebagai teks berpisah koma
-- di sisi induk (pelanggan).
CREATE TABLE pelanggan_lebih (
id integer PRIMARY KEY,
nama text NOT NULL,
pesanan_ids text
);
INSERT INTO pelanggan_lebih (id, nama, pesanan_ids) VALUES
(1, 'Budi Santoso', '101,102'),
(2, 'Siti Rahayu', '103');
-- Menambah satu pesanan baru = menulis ulang baris induk sepenuhnya.
UPDATE pelanggan_lebih
SET pesanan_ids = pesanan_ids || ',104'
WHERE id = 2;
SELECT id, nama, pesanan_ids FROM pelanggan_lebih ORDER BY id;
-- Membaca kembali pun butuh operasi string: pecah teks jadi array,
-- baru bisa dibandingkan dengan tabel pesanan.
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan_lebih p
JOIN pesanan ps
ON ps.id = ANY (string_to_array(p.pesanan_ids, ',')::integer[])
ORDER BY p.id, ps.id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
UPDATE 1
id | nama | pesanan_ids
----+--------------+-------------
1 | Budi Santoso | 101,102
2 | Siti Rahayu | 103,104
(2 rows)
nama | pesanan | total
--------------+---------+-----------
Budi Santoso | 101 | 150000.00
Budi Santoso | 102 | 75000.00
Siti Rahayu | 103 | 220000.00
Siti Rahayu | 104 | 50000.00
(4 rows)Query terakhir di file itu memang berhasil — dan justru keberhasilannya yang menipu. Empat kerusakan struktural bersembunyi di baliknya:
- Menambah anak menulis ulang induk. Menambahkan pesanan 104 ke Siti dilakukan lewat
UPDATEpada baris pelanggan:pesanan_ids || ',104'membaca nilai lama dan menulis nilai baru. Dua kasir yang menambahkan pesanan untuk pelanggan yang sama dalam waktu berdekatan saling menimpa — yang kedua membaca nilai lama, dan pesanan pertama lenyap. - Membaca anak butuh pembedahan string.
string_to_array(p.pesanan_ids, ',')memecah teks menjadi array, lalu::integer[]mengonversi setiap elemen agar bisa dibandingkan denganps.id. Relasi yang seharusnya sekilas pandang kini hanya terbaca lewat dua fungsi string dan satu cast. - Tidak ada foreign key yang mungkin. Constraint
REFERENCEShanya bekerja pada kolom yang nilainya dapat dibandingkan dengan kunci induk — teks berisi banyak angka bukan nilai tunggal, sehingga server tidak punya apa pun yang bisa diperiksa. Pesanan 999 yang tidak pernah ada akan tersimpan tanpa protes. - Tidak ada index yang bisa dipakai join. Index B-tree mengindeks nilai utuh, bukan potongan teks di dalamnya; query
= ANY (string_to_array(...))memaksa konversi per baris setiap kali dijalankan.
Kerusakan kelima bahkan tidak menunggu laporan untuk muncul — satu karakter salah ketik meruntuhkan seluruh query:
-- Satu karakter salah ketik pada daftar koma merusak seluruh relasi:
-- teks bukan integer, dan cast-nya meledak saat query dijalankan.
UPDATE pelanggan_lebih SET pesanan_ids = '10a,104' WHERE id = 2;
SELECT p.nama, ps.id AS pesanan
FROM pelanggan_lebih p
JOIN pesanan ps
ON ps.id = ANY (string_to_array(p.pesanan_ids, ',')::integer[])
ORDER BY p.id, ps.id;Output yang Dihasilkan:
psql:koma-rusak.sql:9: ERROR: invalid input syntax for type integer: "10a"UPDATE pada baris pertama berhasil — teks '10a,104' masuk tanpa penolakan karena kolomnya memang sekadar teks. Kegagalan baru muncul saat query membaca kembali: cast ::integer[] menabrak 10a dan seluruh query mati dengan invalid input syntax for type integer. Pada desain foreign key yang benar, kesalahan seperti ini ditolak tepat di pintu masuk data; pada desain teks, ia menunggu di dalam untuk meledak di depan pengguna.
Arah Foreign Key Adalah Keputusan Arsitektur #
Aturan arahnya bisa dirangkum dalam satu kalimat: kolom rujukan hidup di tabel yang tidak bisa berdiri sendiri. Pesanan tanpa pelanggan masih bisa dipertanyakan maknanya, tetapi pelanggan tanpa pesanan sepenuhnya wajar — maka pelanggan_id menempel di pesanan. Diagram berikut memetakan kedua arah itu:
Pada arah yang benar, satu kolom integer di tabel anak menggantikan seluruh “kolom koleksi” di tabel induk — dan jumlah anak bisa tumbuh tanpa batas tanpa mengubah struktur. Banyak desain berpikir sebaliknya karena terbentuk dari kebiasaan bahasa: “pelanggan punya pesanan” terdengar seperti pelanggan harus menyimpan sesuatu. Database relational menyimpan rujukan sekali di sisi anak, lalu menyusun kembali kepemilikan itu lewat join — persis seperti yang dibuktikan dua query di buat-tabel.sql.
Menghapus Induk: Rekap Kebijakan ON DELETE #
Apa yang terjadi pada anak-anak saat induknya dihapus adalah keputusan bisnis, dan PostgreSQL menawarkan lima aksi referensial untuk menegakkannya. Teori lengkap kelima aksi sudah dibedah di chapter Foreign Key ; rekap singkatnya untuk konteks pelanggan-pesanan:
| Aksi | Nasib pesanan saat pelanggan dihapus | Cocok untuk |
|---|---|---|
NO ACTION / RESTRICT | dihapus ditolak | data induk wajib dipertahankan |
CASCADE | pesanan ikut terhapus | anak tak bermakna tanpa induk |
SET NULL | pelanggan_id dikosongkan | transaksi tetap sah tanpa identitas pelanggan |
SET DEFAULT | pelanggan_id diisi default-nya | jarang; default harus valid di induk |
Perilaku bawaan NO ACTION menolak penghapusan selama masih ada anak yang merujuk. Pemilik toko mencoba menghapus pelanggan Budi yang masih memiliki dua pesanan:
-- Default NO ACTION: induk yang masih dirujuk tidak boleh dihapus.
DELETE FROM pelanggan WHERE id = 1;Output yang Dihasilkan:
psql:hapus-induk.sql:2: ERROR: update or delete on table "pelanggan" violates foreign key constraint "pesanan_pelanggan_id_fkey" on table "pesanan"
DETAIL: Key (id)=(1) is still referenced from table "pesanan".Server menolak dengan Key (id)=(1) is still referenced from table "pesanan" — dua pesanan Budi masih menunjukinya. Untuk data transaksi Toko Maju Jaya, keputusan yang dipilih adalah SET NULL: pesanan adalah fakta penjualan yang tetap sah meski akun pelanggannya sudah ditutup. Constraint diganti lewat ALTER TABLE:
-- Kebijakan diganti: pesanan tidak wajib berinduk, jadi rujukan
-- cukup dikosongkan saat pelanggan dihapus (ON DELETE SET NULL).
ALTER TABLE pesanan DROP CONSTRAINT pesanan_pelanggan_id_fkey;
ALTER TABLE pesanan
ADD CONSTRAINT pesanan_pelanggan_id_fkey
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan (id)
ON DELETE SET NULL;
DELETE FROM pelanggan WHERE id = 1;
-- Pesanan Budi tetap ada sebagai data transaksi, pelanggan_id kosong.
SELECT id, tanggal, pelanggan_id FROM pesanan ORDER BY id;Output yang Dihasilkan:
ALTER TABLE
ALTER TABLE
DELETE 1
id | tanggal | pelanggan_id
-----+------------+--------------
101 | 2026-01-05 |
102 | 2026-01-18 |
103 | 2026-01-09 | 2
104 | 2026-02-02 | 4
105 | 2026-02-10 |
(5 rows)Dua langkah ALTER TABLE di awal file menjatuhkan constraint bawaan lalu memasang ulang versi ON DELETE SET NULL — nama constraint-nya sengaja dipertahankan sama agar aplikasi tidak kehilangan jejak. Setelah DELETE FROM pelanggan WHERE id = 1, kelima baris pesanan tetap utuh: pesanan 101 dan 102 milik Budi kini berdiri dengan pelanggan_id kosong, sementara nomor pesanan, tanggal, dan totalnya — seluruh nilai transaksi — tetap tercatat untuk laporan penjualan.
Dengan sisi satu-ke-banyak beres, pertanyaan berikutnya sudah menunggu di rak toko: satu pesanan berisi Buku Tulis sekaligus Pulpen, dan satu Buku Tulis terjual di banyak pesanan berbeda. Kedua sisi sama-sama “banyak”, dan tidak ada kolom rujukan tunggal yang bisa menampungnya — chapter berikutnya membuktikan kebuntuan itu lalu memperkenalkan bentuk relasi ketiga.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
anti-pola-koma.sql
Unduh berkas-- Anti-pola: daftar id pesanan disimpan sebagai teks berpisah koma
-- di sisi induk (pelanggan).
CREATE TABLE pelanggan_lebih (
id integer PRIMARY KEY,
nama text NOT NULL,
pesanan_ids text
);
INSERT INTO pelanggan_lebih (id, nama, pesanan_ids) VALUES
(1, 'Budi Santoso', '101,102'),
(2, 'Siti Rahayu', '103');
-- Menambah satu pesanan baru = menulis ulang baris induk sepenuhnya.
UPDATE pelanggan_lebih
SET pesanan_ids = pesanan_ids || ',104'
WHERE id = 2;
SELECT id, nama, pesanan_ids FROM pelanggan_lebih ORDER BY id;
-- Membaca kembali pun butuh operasi string: pecah teks jadi array,
-- baru bisa dibandingkan dengan tabel pesanan.
SELECT p.nama, ps.id AS pesanan, ps.total
FROM pelanggan_lebih p
JOIN pesanan ps
ON ps.id = ANY (string_to_array(p.pesanan_ids, ',')::integer[])
ORDER BY p.id, ps.id;
buat-tabel.sql
Unduh berkas-- Relasi one-to-many: kolom rujukan selalu di sisi anak (banyak).
-- Satu pelanggan punya banyak pesanan; setiap pesanan punya satu pelanggan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL,
kota text NOT NULL,
email text NOT NULL UNIQUE
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
total numeric(12, 2) NOT NULL,
pelanggan_id integer REFERENCES pelanggan (id)
);
INSERT INTO pelanggan (id, nama, kota, email) VALUES
(1, 'Budi Santoso', 'Bandung', '[email protected]'),
(2, 'Siti Rahayu', 'Jakarta', '[email protected]'),
(3, 'Andi Wijaya', 'Surabaya', '[email protected]'),
(4, 'Dewi Lestari', 'Medan', '[email protected]');
INSERT INTO pesanan (id, tanggal, total, pelanggan_id) VALUES
(101, DATE '2026-01-05', 150000.00, 1),
(102, DATE '2026-01-18', 75000.00, 1),
(103, DATE '2026-01-09', 220000.00, 2),
(104, DATE '2026-02-02', 50000.00, 4),
(105, DATE '2026-02-10', 30000.00, NULL);
-- Satu induk, banyak anak: Budi tampil di dua baris,
-- pesanan 105 (tunai kasir) tidak punya induk sama sekali.
SELECT ps.id, ps.tanggal, ps.total, p.nama
FROM pesanan ps
LEFT JOIN pelanggan p ON p.id = ps.pelanggan_id
ORDER BY ps.id;
-- Agregasi per induk, pelanggan tanpa pesanan tetap terhitung nol.
SELECT p.id, p.nama, COUNT(ps.id) AS jumlah_pesanan
FROM pelanggan p
LEFT JOIN pesanan ps ON ps.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;
hapus-induk.sql
Unduh berkas-- Default NO ACTION: induk yang masih dirujuk tidak boleh dihapus.
DELETE FROM pelanggan WHERE id = 1;
koma-rusak.sql
Unduh berkas-- Satu karakter salah ketik pada daftar koma merusak seluruh relasi:
-- teks bukan integer, dan cast-nya meledak saat query dijalankan.
UPDATE pelanggan_lebih SET pesanan_ids = '10a,104' WHERE id = 2;
SELECT p.nama, ps.id AS pesanan
FROM pelanggan_lebih p
JOIN pesanan ps
ON ps.id = ANY (string_to_array(p.pesanan_ids, ',')::integer[])
ORDER BY p.id, ps.id;
set-null.sql
Unduh berkas-- Kebijakan diganti: pesanan tidak wajib berinduk, jadi rujukan
-- cukup dikosongkan saat pelanggan dihapus (ON DELETE SET NULL).
ALTER TABLE pesanan DROP CONSTRAINT pesanan_pelanggan_id_fkey;
ALTER TABLE pesanan
ADD CONSTRAINT pesanan_pelanggan_id_fkey
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan (id)
ON DELETE SET NULL;
DELETE FROM pelanggan WHERE id = 1;
-- Pesanan Budi tetap ada sebagai data transaksi, pelanggan_id kosong.
SELECT id, tanggal, pelanggan_id FROM pesanan ORDER BY id;