Relasi one-to-many punya satu kolom penghubung karena hanya satu sisi yang berjumlah banyak. Keadaan berubah pada relasi many-to-many: satu pesanan boleh memuat banyak produk, dan satu produk boleh masuk ke banyak pesanan — tidak ada tabel yang bisa memegang daftar id lawannya tanpa melanggar aturan nilai atomik. Solusinya adalah tabel junction, tabel ketiga yang khusus menyimpan pasangan relasi. Chapter ini membangun relasi pesanan ↔ produk dan menunjukkan bahwa tabel junction bisa membawa atributnya sendiri.

Skema Contoh: Tabel Junction #

Dua tabel utama tetap polos tanpa saling menunjuk. Semua koneksi dipindahkan ke tabel junction detail_pesanan:

sql
CREATE TABLE pesanan (
  id INTEGER PRIMARY KEY,
  tanggal TEXT NOT NULL
);
CREATE TABLE produk (
  id INTEGER PRIMARY KEY,
  nama TEXT NOT NULL,
  harga INTEGER NOT NULL
);
CREATE TABLE detail_pesanan (
  pesanan_id INTEGER NOT NULL REFERENCES pesanan(id),
  produk_id INTEGER NOT NULL REFERENCES produk(id),
  jumlah INTEGER NOT NULL,
  harga_saat_transaksi INTEGER NOT NULL,
  PRIMARY KEY (pesanan_id, produk_id)
);

INSERT INTO pesanan VALUES
  (1, '2025-01-05'),
  (2, '2025-01-06');
INSERT INTO produk VALUES
  (1, 'Kopi Gayo', 45000),
  (2, 'Gula Aren', 25000),
  (3, 'Teh Melati', 18000);
INSERT INTO detail_pesanan VALUES
  (1, 1, 2, 45000),
  (1, 2, 1, 25000),
  (2, 3, 3, 18000),
  (2, 1, 1, 40000);

Setiap baris detail_pesanan berarti “pesanan ini memuat sekian banyak produk ini”. Pesanan 1 berisi Kopi Gayo dan Gula Aren; Kopi Gayo sekaligus tercatat di pesanan 1 dan pesanan 2 — relasi many-to-many terpenuhi.

Composite Primary Key Menjaga Pasangan Unik #

Primary key gabungan (pesanan_id, produk_id) membuat satu pasangan pesanan-produk hanya boleh muncul satu kali. Percobaan menambahkan Teh Melati lagi ke pesanan 2 ditolak:

sql
INSERT INTO detail_pesanan VALUES (2, 3, 1, 18000);

Output yang Dihasilkan:

text
Runtime error: UNIQUE constraint failed: detail_pesanan.pesanan_id, detail_pesanan.produk_id (19)

Tanpa primary key ini, typo INSERT bisa menghasilkan dua baris untuk pasangan yang sama, dan sum(jumlah) akan menghitung ganda. Alternatif desainnya adalah kolom id INTEGER PRIMARY KEY plus UNIQUE (pesanan_id, produk_id) — hasil penegakannya sama; composite key dipilih di sini karena pasangan itu memang identitas alami barisnya.

Atribut Milik Relasi #

Kolom jumlah dan harga_saat_transaksi hanya bermakna untuk satu pasangan pesanan-produk, sehingga tempatnya memang di tabel junction. Peran harga_saat_transaksi penting: harga di tabel produk berubah mengikuti waktu, tetapi harga yang benar-benar dibayar harus membeku di transaksi. Data kita sengaja memuat satu selisih — pesanan 2 membeli Kopi Gayo saat ada potongan harga:

sql
SELECT pr.nama, pr.harga AS harga_kini, d.harga_saat_transaksi
FROM detail_pesanan d
JOIN produk pr ON d.produk_id = pr.id
WHERE d.harga_saat_transaksi <> pr.harga
ORDER BY pr.nama;

Output yang Dihasilkan:

text
Kopi Gayo|45000|40000

Harga kini sudah kembali ke 45000, tetapi laporan pesanan 2 tetap harus menghitung 40000 — angka tersebut terselamatkan justru karena disimpan di detail_pesanan, bukan dibaca ulang dari produk.

Query Lintas Tiga Tabel #

Membaca relasi many-to-many selalu menembus junction. Query berikut merakit daftar belanja lengkap dengan subtotal per item:

sql
SELECT o.tanggal, pr.nama, d.jumlah, d.harga_saat_transaksi,
       d.jumlah * d.harga_saat_transaksi AS subtotal
FROM detail_pesanan d
JOIN pesanan o ON d.pesanan_id = o.id
JOIN produk pr ON d.produk_id = pr.id
ORDER BY o.id, pr.nama;

Output yang Dihasilkan:

text
2025-01-05|Gula Aren|1|25000|25000
2025-01-05|Kopi Gayo|2|45000|90000
2025-01-06|Kopi Gayo|1|40000|40000
2025-01-06|Teh Melati|3|18000|54000

Bedah singkat query di atas:

  1. Baris 4 (FROM detail_pesanan d): junction dipakai sebagai tabel dasar karena setiap barisnya sudah memegang dua foreign key.
  2. Baris 5-6 (JOIN pesanan o ... JOIN produk pr ...): dua JOIN menempelkan data dari kedua ujung relasi melalui masing-masing foreign key.
  3. Baris 2 (d.jumlah * d.harga_saat_transaksi AS subtotal): subtotal dihitung dari kolom junction, bukan harga terkini — konsisten dengan pembekuan harga tadi.

Arah query bisa dibalik tanpa mengubah skema: dari sisi produk, kita bisa mencari pesanan mana saja yang memuat Kopi Gayo:

sql
SELECT o.tanggal, d.jumlah
FROM produk pr
JOIN detail_pesanan d ON d.produk_id = pr.id
JOIN pesanan o ON d.pesanan_id = o.id
WHERE pr.nama = 'Kopi Gayo'
ORDER BY o.id;

Output yang Dihasilkan:

text
2025-01-05|2
2025-01-06|1

Agregat lintas relasi juga bekerja seperti biasa, misalnya produk terlaris:

sql
SELECT pr.nama, sum(d.jumlah) AS total_terjual
FROM detail_pesanan d
JOIN produk pr ON d.produk_id = pr.id
GROUP BY pr.id
ORDER BY total_terjual DESC, pr.nama;

Output yang Dihasilkan:

text
Kopi Gayo|3
Teh Melati|3
Gula Aren|1

Skema detail_pesanan kini penuh dengan REFERENCES yang belum ditegakkan. Chapter Foreign Key membahas cara mengaktifkannya beserta aksi ON DELETE yang mengatur nasib baris anak saat induknya dihapus.