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:

sql buat-tabel.sql
-- 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:

text
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:

  1. pelanggan_id integer REFERENCES pelanggan (id) ada di tabel pesanan — 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.
  2. 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 tabel pelanggan.
  3. Pesanan 105 tampil dengan kolom nama kosong — transaksi tunai kasir tanpa akun pelanggan. Kolom rujukan yang tidak diberi NOT NULL membuat 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:

text
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:

  1. Menambah anak menulis ulang induk. Menambahkan pesanan 104 ke Siti dilakukan lewat UPDATE pada 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.
  2. Membaca anak butuh pembedahan string. string_to_array(p.pesanan_ids, ',') memecah teks menjadi array, lalu ::integer[] mengonversi setiap elemen agar bisa dibandingkan dengan ps.id. Relasi yang seharusnya sekilas pandang kini hanya terbaca lewat dua fungsi string dan satu cast.
  3. Tidak ada foreign key yang mungkin. Constraint REFERENCES hanya 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.
  4. 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:

sql koma-rusak.sql
-- 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:

text
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:

AksiNasib pesanan saat pelanggan dihapusCocok untuk
NO ACTION / RESTRICTdihapus ditolakdata induk wajib dipertahankan
CASCADEpesanan ikut terhapusanak tak bermakna tanpa induk
SET NULLpelanggan_id dikosongkantransaksi tetap sah tanpa identitas pelanggan
SET DEFAULTpelanggan_id diisi default-nyajarang; 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:

sql hapus-induk.sql
-- Default NO ACTION: induk yang masih dirujuk tidak boleh dihapus.
DELETE FROM pelanggan WHERE id = 1;

Output yang Dihasilkan:

text
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:

sql set-null.sql
-- 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:

text
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
sql
-- 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
sql
-- 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
sql
-- Default NO ACTION: induk yang masih dirujuk tidak boleh dihapus.
DELETE FROM pelanggan WHERE id = 1;

koma-rusak.sql

Unduh berkas
sql
-- 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
sql
-- 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;