Dua chapter sebelumnya menuntaskan relasi yang bisa ditampung satu kolom rujukan: satu-ke-satu dan satu-ke-banyak. Lalu kehidupan toko mengajukan tuntutan yang lebih tinggi: pesanan 501 berisi sepuluh Buku Tulis sekaligus lima Pulpen, dan Buku Tulis yang sama terjual lagi di pesanan 502. Satu pesanan memuat banyak produk; satu produk masuk banyak pesanan; kedua sisi sama-sama “banyak”. Relasi many-to-many seperti ini tidak bisa dibentuk dengan satu foreign key di sisi mana pun — dan justru karena itu, ia menjadi momen ketika desainer database belajar bahwa relasi kadang harus menjadi entitas tersendiri. Chapter ini membuktikan kebuntuannya lewat dua percobaan yang gagal, lalu memperkenalkan tabel perantara yang menyelesaikannya.

Bentuk Relasi yang Menuntut Lebih dari Satu Kolom #

Ciri relasi banyak-ke-banyak dapat dikenali langsung dari dua pertanyaan yang sama-sama dijawab “banyak”:

Pasangan entitasSatu baris sisi kiri berhubungan dengan…Satu baris sisi kanan berhubungan dengan…Tabel perantara lazim
siswa ↔ mata kuliahbanyak mata kuliahbanyak siswaambil / peserta
produk ↔ pesananbanyak pesananbanyak produkitem_pesanan
aktor ↔ filmbanyak filmbanyak aktorperan
dokter ↔ pasienbanyak pasienbanyak dokterkunjungan
tag ↔ artikelbanyak artikelbanyak tagartikel_tag

Perhatikan pola pada kolom terakhir: relasi banyak-ke-banyak selalu mendarat pada satu tabel tambahan di antara kedua entitas. Nama-nama seperti peran dan kunjungan bahkan memberi petunjuk penting: tabel perantara sering kali bukan sekadar penghubung, melainkan entitas nyata yang memiliki atributnya sendiri. Kita akan tiba di sana — tetapi lebih meyakinkan bila kebutuhan itu muncul dari kegagalan, bukan dari kesimpulan yang diberitahukan.

Percobaan Pertama: Satu Foreign Key di Sisi Pesanan #

Insting pertama kebanyakan perancang: taruh rujukan produk di tabel pesanan, karena pesanan “memuat” produk. Mari kita bangun desain itu dan lihat apa yang pecah:

-- Percobaan pertama: produk dirujuk satu FK di tabel pesanan.
-- Satu pesanan hanya muat SATU produk.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

CREATE TABLE pesanan_salah (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    nama_pembeli text NOT NULL,
    produk_id    integer NOT NULL REFERENCES produk (id),
    kuantitas    integer NOT NULL
);

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Buku Tulis',   4500.00),
    (2, 'Pulpen Hitam', 3000.00);

INSERT INTO pesanan_salah (id, tanggal, nama_pembeli, produk_id, kuantitas) VALUES
    (501, DATE '2026-03-01', 'Budi Santoso', 1, 10);

-- Budi ingin menambah Pulpen ke pesanan yang sama: tidak ada jalurnya.
-- Satu-satunya jalan adalah membuat baris "pesanan" baru
-- yang meniru tanggal dan pembeli yang sama.
INSERT INTO pesanan_salah (id, tanggal, nama_pembeli, produk_id, kuantitas) VALUES
    (502, DATE '2026-03-01', 'Budi Santoso', 2, 5);

SELECT id, tanggal, nama_pembeli, produk_id, kuantitas
FROM pesanan_salah
ORDER BY id;

-- Laporan "jumlah pesanan" menjadi bohong:
-- satu kunjungan terhitung sebagai dua pesanan.
SELECT nama_pembeli, COUNT(*) AS jumlah_pesanan
FROM pesanan_salah
GROUP BY nama_pembeli
ORDER BY nama_pembeli;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 1
INSERT 0 1
 id  |  tanggal   | nama_pembeli | produk_id | kuantitas 
-----+------------+--------------+-----------+-----------
 501 | 2026-03-01 | Budi Santoso |         1 |        10
 502 | 2026-03-01 | Budi Santoso |         2 |         5
(2 rows)

 nama_pembeli | jumlah_pesanan 
--------------+----------------
 Budi Santoso |              2
(1 row)

Foreign key produk_id REFERENCES produk (id) bekerja sempurna — rujukan yang tidak ada akan ditolak server. Yang rusak bukan integritas rujukan, melainkan bentuk relasinya:

  1. produk_id hanya muat satu nilai per baris. Begitu Budi menambahkan Pulpen ke pesanan yang sudah berisi Buku Tulis, tidak ada kolom yang bisa menampung produk kedua itu.
  2. Solusi darurat yang muncul adalah menduplikasi kepala pesanan. Baris 502 lahir hanya untuk menampung Pulpen: tanggal dan nama pembeli disalin persis dari baris 501. Padahal dalam kenyataan, Budi hanya membuat satu kunjungan dan satu pesanan.
  3. Laporan mulai berbohong. Query penutup melaporkan Budi membuat dua pesanan, padahal kenyataannya satu. Semua agregasi turunan — frekuensi kunjungan, rata-rata nilai pesanan, biaya kirim — ikut terdistorsi sejak desain ini dipasang.

Kerusakan ini persis keluarga anomali yang dibongkar di chapter Konsep Relasi Antartabel : satu fakta (tanggal dan pembeli pesanan 501) tersimpan di banyak baris, dan integritasnya tinggal menunggu waktu untuk retak.

Percobaan Kedua: Satu Foreign Key di Sisi Produk #

Insting kedua membalik arahnya: mungkin rujukan pesanan yang seharusnya hidup di tabel produk. Percobaan ini membangun versi terbaliknya:

-- Percobaan kedua: rujukan dipindah ke tabel produk.
-- Sekarang satu produk hanya bisa tercatat di SATU pesanan.
DROP TABLE IF EXISTS pesanan_salah CASCADE;

CREATE TABLE pesanan (
    id      integer PRIMARY KEY,
    tanggal date NOT NULL
);

CREATE TABLE produk_salah (
    id         integer PRIMARY KEY,
    nama       text NOT NULL,
    harga      numeric(12, 2) NOT NULL,
    pesanan_id integer REFERENCES pesanan (id)
);

INSERT INTO pesanan (id, tanggal) VALUES
    (501, DATE '2026-03-01'),
    (502, DATE '2026-03-05');

INSERT INTO produk_salah (id, nama, harga, pesanan_id) VALUES
    (1, 'Buku Tulis',   4500.00, 501),
    (2, 'Pulpen Hitam', 3000.00, 501);

-- Buku Tulis terjual lagi di pesanan 502: kolom hanya muat satu nilai,
-- jadi penjualan di 501 tertimpa dan riwayatnya hilang.
UPDATE produk_salah SET pesanan_id = 502 WHERE id = 1;

SELECT p.id AS produk, p.nama, p.pesanan_id
FROM produk_salah p
ORDER BY p.id;

Output yang Dihasilkan:

text
DROP TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 2
UPDATE 1
 produk |     nama     | pesanan_id 
--------+--------------+------------
      1 | Buku Tulis   |        502
      2 | Pulpen Hitam |        501
(2 rows)

Argumen simetris: kolom pesanan_id REFERENCES pesanan (id) kini berdiri di sisi produk, dan produk memang “terjual dalam” pesanan. Kegagalannya simetris juga:

  1. pesanan_id hanya muat satu nilai per produk. Buku Tulis tercatat di pesanan 501, lalu terjual lagi di pesanan 502 — kolom tersebut harus memilih salah satu.
  2. UPDATE produk_salah SET pesanan_id = 502 menimpa riwayat. Hasil akhirnya tampak rapi, tapi penjualan sepuluh Buku Tulis di pesanan 501 baru saja lenyap dari database: tidak ada kolom, tidak ada baris, tidak ada jejak. Penghapusan data lewat penimpaan adalah jenis kerusakan paling sunyi karena tidak meninggalkan error apa pun.
  3. Agregasi dari sisi produk ikut salah. Setiap produk hanya bisa dilaporkan ikut satu pesanan, padahal Buku Tulis sejatinya hadir di dua transaksi.

Dua percobaan ini menghasilkan pelajaran yang sama dari dua arah berlawanan: kolom rujukan tunggal selalu hanya menampung satu pasangan. Relasi satu-ke-banyak selamat karena hanya sisi satu yang perlu “banyak” — tapi ketika kedua sisi menuntut banyak, satu kolom di sisi mana pun tidak akan pernah cukup.

Jalan Buntu Ketiga: Daftar ID dalam Satu Kolom #

Ada satu pelarian yang tampak menjanjikan: simpan daftar id produk sebagai teks di kolom pesanan, atau sebaliknya. Chapter Relasi One-to-Many sudah menghancurkan pola teks berpisah koma sampai ke detailnya — foreign key tidak mungkin, index tidak bisa dipakai, dan satu karakter salah ketik meruntuhkan query. Untuk relasi banyak-ke-banyak, pola ini punya kelemahan tambahan yang lebih fatal: atribut per pasangan tidak punya tempat sama sekali. Kuantitas Buku Tulis di pesanan 501 adalah fakta milik pasangan (501, 1) — bukan milik pesanan saja, bukan milik produk saja — dan teks '1:10,2:5' bukanlah tempat tinggal bagi sebuah angka yang ingin dijumlahkan.

Tabel Perantara: Relasi yang Menjadi Entitas #

Jalan keluarnya elegan: letakkan pasangan rujukan di tabel baru yang berdiri sendiri. Satu baris perantara = satu pasangan (pesanan, produk):

-- Solusi: tabel perantara memegang DUA foreign key sekaligus.
-- Relasi banyak-ke-banyak pecah menjadi dua relasi satu-ke-banyak.
DROP TABLE IF EXISTS produk_salah CASCADE;

CREATE TABLE item_pesanan (
    id_pesanan integer NOT NULL REFERENCES pesanan (id),
    id_produk  integer NOT NULL REFERENCES produk (id),
    kuantitas  integer NOT NULL,
    PRIMARY KEY (id_pesanan, id_produk)
);

INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas) VALUES
    (501, 1, 10),
    (501, 2,  5),
    (502, 1,  3);

-- Satu pesanan memuat banyak produk, satu produk masuk banyak pesanan.
SELECT ps.id AS pesanan, ps.tanggal, pr.nama AS produk, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
JOIN produk pr ON pr.id = i.id_produk
ORDER BY ps.id, pr.id;

-- Produk yang sama kini tercatat di dua pesanan tanpa menimpa apa pun.
SELECT pr.nama, COUNT(i.id_pesanan) AS jumlah_pesanan
FROM produk pr
LEFT JOIN item_pesanan i ON i.id_produk = pr.id
GROUP BY pr.id, pr.nama
ORDER BY pr.id;

Output yang Dihasilkan:

text
DROP TABLE
CREATE TABLE
INSERT 0 3
 pesanan |  tanggal   |    produk    | kuantitas 
---------+------------+--------------+-----------
     501 | 2026-03-01 | Buku Tulis   |        10
     501 | 2026-03-01 | Pulpen Hitam |         5
     502 | 2026-03-05 | Buku Tulis   |         3
(3 rows)

     nama     | jumlah_pesanan 
--------------+----------------
 Buku Tulis   |              2
 Pulpen Hitam |              1
(2 rows)

Bacaan struktur dan hasilnya saksikan solusi ini dari dua sisi:

  1. item_pesanan memegang dua foreign key sekaligus. id_pesanan merujuk pesanan, id_produk merujuk produk. Tabel perantara adalah anak dari dua induk sekaligus, dan setiap relasi induk-anak itu sendiri satu-ke-banyak.
  2. PRIMARY KEY (id_pesanan, id_produk) — identitas baris perantara adalah pasangannya. Satu kombinasi pesanan-produk hanya boleh muncul sekali; menambahkan Buku Tulis lagi ke pesanan 501 akan ditolak server.
  3. Laporan pertama kini jujur. Pesanan 501 tampil dua baris — satu per produk — tanpa menduplikasi tanggal, dan pesanan 502 menampung Buku Tulis yang sama tanpa menimpa riwayat 501.
  4. Agregasi per produk pulih. Buku Tulis tercatat di dua pesanan, Pulpen di satu, dan LEFT JOIN menjaga produk tanpa transaksi tetap terhitung nol.

Diagram berikut memetakan bentuk finalnya — simbol berlian pada jalur pesanan-produk inilah yang disebut para perancang sebagai pola diamond:

Dua relasi satu-ke-banyak bertemu di item_pesanan, dan bersama-sama mereka menampung relasi banyak-ke-banyak yang mustahil ditampung satu kolom. Yang belum tampak pada chapter ini adalah seluruh keputusan yang menyertai tabel perantara: atribut apa saja yang layak menempel padanya, bagaimana memperlakukan harga yang berubah antarwaktu, dan kapan keunikan pasangan sengaja dilonggarkan. Chapter berikutnya mengangkat tabel perantara dari sekadar pola menjadi perangkat desain yang utuh: junction table.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

arah-terbalik.sql

Unduh berkas
sql
-- Percobaan kedua: rujukan dipindah ke tabel produk.
-- Sekarang satu produk hanya bisa tercatat di SATU pesanan.
DROP TABLE IF EXISTS pesanan_salah CASCADE;

CREATE TABLE pesanan (
    id      integer PRIMARY KEY,
    tanggal date NOT NULL
);

CREATE TABLE produk_salah (
    id         integer PRIMARY KEY,
    nama       text NOT NULL,
    harga      numeric(12, 2) NOT NULL,
    pesanan_id integer REFERENCES pesanan (id)
);

INSERT INTO pesanan (id, tanggal) VALUES
    (501, DATE '2026-03-01'),
    (502, DATE '2026-03-05');

INSERT INTO produk_salah (id, nama, harga, pesanan_id) VALUES
    (1, 'Buku Tulis',   4500.00, 501),
    (2, 'Pulpen Hitam', 3000.00, 501);

-- Buku Tulis terjual lagi di pesanan 502: kolom hanya muat satu nilai,
-- jadi penjualan di 501 tertimpa dan riwayatnya hilang.
UPDATE produk_salah SET pesanan_id = 502 WHERE id = 1;

SELECT p.id AS produk, p.nama, p.pesanan_id
FROM produk_salah p
ORDER BY p.id;

desain-salah.sql

Unduh berkas
sql
-- Percobaan pertama: produk dirujuk satu FK di tabel pesanan.
-- Satu pesanan hanya muat SATU produk.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

CREATE TABLE pesanan_salah (
    id           integer PRIMARY KEY,
    tanggal      date NOT NULL,
    nama_pembeli text NOT NULL,
    produk_id    integer NOT NULL REFERENCES produk (id),
    kuantitas    integer NOT NULL
);

INSERT INTO produk (id, nama, harga) VALUES
    (1, 'Buku Tulis',   4500.00),
    (2, 'Pulpen Hitam', 3000.00);

INSERT INTO pesanan_salah (id, tanggal, nama_pembeli, produk_id, kuantitas) VALUES
    (501, DATE '2026-03-01', 'Budi Santoso', 1, 10);

-- Budi ingin menambah Pulpen ke pesanan yang sama: tidak ada jalurnya.
-- Satu-satunya jalan adalah membuat baris "pesanan" baru
-- yang meniru tanggal dan pembeli yang sama.
INSERT INTO pesanan_salah (id, tanggal, nama_pembeli, produk_id, kuantitas) VALUES
    (502, DATE '2026-03-01', 'Budi Santoso', 2, 5);

SELECT id, tanggal, nama_pembeli, produk_id, kuantitas
FROM pesanan_salah
ORDER BY id;

-- Laporan "jumlah pesanan" menjadi bohong:
-- satu kunjungan terhitung sebagai dua pesanan.
SELECT nama_pembeli, COUNT(*) AS jumlah_pesanan
FROM pesanan_salah
GROUP BY nama_pembeli
ORDER BY nama_pembeli;

tebak-junction.sql

Unduh berkas
sql
-- Solusi: tabel perantara memegang DUA foreign key sekaligus.
-- Relasi banyak-ke-banyak pecah menjadi dua relasi satu-ke-banyak.
DROP TABLE IF EXISTS produk_salah CASCADE;

CREATE TABLE item_pesanan (
    id_pesanan integer NOT NULL REFERENCES pesanan (id),
    id_produk  integer NOT NULL REFERENCES produk (id),
    kuantitas  integer NOT NULL,
    PRIMARY KEY (id_pesanan, id_produk)
);

INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas) VALUES
    (501, 1, 10),
    (501, 2,  5),
    (502, 1,  3);

-- Satu pesanan memuat banyak produk, satu produk masuk banyak pesanan.
SELECT ps.id AS pesanan, ps.tanggal, pr.nama AS produk, i.kuantitas
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
JOIN produk pr ON pr.id = i.id_produk
ORDER BY ps.id, pr.id;

-- Produk yang sama kini tercatat di dua pesanan tanpa menimpa apa pun.
SELECT pr.nama, COUNT(i.id_pesanan) AS jumlah_pesanan
FROM produk pr
LEFT JOIN item_pesanan i ON i.id_produk = pr.id
GROUP BY pr.id, pr.nama
ORDER BY pr.id;