Chapter sebelumnya berakhir pada satu baris penting: relasi banyak-ke-banyak mendarat pada tabel perantara yang berdiri sendiri. Tabel perantara itu punya nama resmi — junction table — dan layak dibahas sebagai topik penuh, bukan catatan kaki, karena tiga keputusan desain menumpuk di atasnya: apa identitas barisnya, atribut apa yang boleh menempel padanya, dan seberapa ketat keunikan pasangannya. Kesalahan di ketiga keputusan itu tidak membuat query gagal hari ini; ia membuat data salah diam-diam — total pesanan yang terhitung ganda, harga lama yang ikut berubah, atau produk yang sama tercatat dua kali tanpa yang menyadari. Kita melanjutkan kasus Toko Maju Jaya dengan junction table yang lengkap.

Anatomi Junction Table #

Junction table minimal memuat dua kolom rujukan yang masing-masing menjadi foreign key ke satu induk. Identitas barisnya adalah pasangan rujukan itu sendiri, dinyatakan sebagai composite primary key:

-- Junction table lengkap: dua foreign key, composite primary key,
-- plus atribut milik relasi itu sendiri (kuantitas, harga saat transaksi).
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL
);

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

CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

CREATE TABLE item_pesanan (
    id_pesanan           integer NOT NULL REFERENCES pesanan (id) ON DELETE CASCADE,
    id_produk            integer NOT NULL REFERENCES produk (id),
    kuantitas            integer NOT NULL CHECK (kuantitas > 0),
    harga_saat_transaksi numeric(12, 2) NOT NULL,
    PRIMARY KEY (id_pesanan, id_produk)
);

INSERT INTO pelanggan (id, nama) VALUES
    (1, 'Budi Santoso'),
    (2, 'Siti Rahayu');

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

INSERT INTO pesanan (id, tanggal, pelanggan_id) VALUES
    (101, DATE '2026-03-01', 1),
    (102, DATE '2026-03-05', 2);

INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
    (101, 1, 10,  4500.00),
    (101, 2,  5,  3000.00),
    (102, 3,  1, 55000.00);

-- Menyusuri "berlian": pesanan -> item -> produk (plus pelanggan).
SELECT ps.id AS pesanan, ps.tanggal, pl.nama AS pelanggan,
       pr.nama AS produk, i.kuantitas, i.harga_saat_transaksi
FROM pesanan ps
JOIN pelanggan pl   ON pl.id = ps.pelanggan_id
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;

-- Total per pesanan dihitung dari junction, bukan disimpan tebak-tebakan.
SELECT ps.id AS pesanan,
       SUM(i.kuantitas * i.harga_saat_transaksi) AS total
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id
ORDER BY ps.id;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
INSERT 0 2
INSERT 0 3
 pesanan |  tanggal   |  pelanggan   |    produk     | kuantitas | harga_saat_transaksi 
---------+------------+--------------+---------------+-----------+----------------------
     101 | 2026-03-01 | Budi Santoso | Buku Tulis    |        10 |              4500.00
     101 | 2026-03-01 | Budi Santoso | Pulpen Hitam  |         5 |              3000.00
     102 | 2026-03-05 | Siti Rahayu  | Kertas HVS A4 |         1 |             55000.00
(3 rows)

 pesanan |  total   
---------+----------
     101 | 60000.00
     102 | 55000.00
(2 rows)

Enam keputusan pada file tersebut membentuk karakter junction table yang sehat:

  1. Dua foreign key, satu baris per pasangan — id_pesanan dan id_produk sama-sama merujuk induknya. Junction table adalah anak dari dua induk sekaligus; kedua rujukan diperiksa server secara independen.
  2. PRIMARY KEY (id_pesanan, id_produk) — composite primary key yang dibahas di chapter Composite Key . Identitas baris perantara memang pasangannya: baris keempat dengan kombinasi (101, 1) tidak bermakna karena kombinasi itu sudah terwakili.
  3. ON DELETE CASCADE pada id_pesanan — baris item adalah milik pesanannya: saat pesanan dihapus, item-itemnya ikut menyusul. Rujukan ke produk sengaja tidak diberi cascade — menghapus produk tidak boleh menyapu riwayat transaksi.
  4. kuantitas integer NOT NULL CHECK (kuantitas > 0) — atribut milik pasangan, bukan milik pesanan atau produk sendirian.
  5. harga_saat_transaksi numeric(12, 2) — atribut terpenting sekaligus paling sering dilupakan; bahasan khususnya dua subbab berikutnya.
  6. Query penutup menghitung total — SUM(kuantitas * harga_saat_transaksi) dihitung dari junction saat dibutuhkan, bukan disimpan sebagai tebakan di tabel pesanan.

Query pertama file itu menyusuri rute yang oleh perancang skema disebut berlian (diamond): pesanan → item_pesanan → produk, dengan pelanggan bergabung dari sisi kepala pesanan. Tiga join, satu laporan, tanpa satu pun data diduplikasi.

Atribut yang Milik Relasi Itu Sendiri #

Pertanyaan “atribut ini milik siapa” adalah inti perancangan junction table. Kuantitas sepuluh pada baris (101, 1) adalah fakta tentang pasangan pesanan-101-dan-Buku-Tulis: pesanan 101 saja tidak punya kuantitas, Buku Tulis saja tidak punya kuantitas. Tabel berikut memetakan kepemilikan atribut pada skema kita:

AtributMilikTempat tinggal yang benar
nama, harga katalogprodukproduk
tanggalpesananpesanan
nama pelangganpelangganpelanggan
kuantitaspasangan pesanan-produkitem_pesanan
harga_saat_transaksipasangan pesanan-produkitem_pesanan

Uji cepatnya: bila atribut itu masih masuk akal saat pasangan berganti — kuantitas pasti berbeda di pesanan lain — atribut itu milik pasangan. Atribut yang tidak lulus uji ini tapi tetap dititipkan di junction table akan berulang dengan nilai sama di banyak baris; gejala redundansi itu sendiri adalah materi pembuka chapter Normalisasi Database di akhir section ini.

Pola Snapshot: Harga Historis di Junction #

harga_saat_transaksi menjawab masalah yang tidak dimiliki relasi satu-ke-banyak: waktu. Harga katalog berubah, tapi pesanan yang sudah terjadi adalah fakta masa lalu — totalnya tidak boleh ikut berubah. Solusinya disebut pola snapshot: salin harga ke junction saat transaksi terjadi, lalu biarkan katalog bergerak sendiri:

-- Waktu berjalan: harga Buku Tulis naik di katalog produk.
UPDATE produk SET harga = 5000.00 WHERE id = 1;

-- Pesanan lama tidak ikut berubah: harga_saat_transaksi adalah
-- foto harga pada saat transaksi terjadi, bukan harga hari ini.
SELECT pr.nama,
       pr.harga AS harga_katalog,
       i.harga_saat_transaksi AS harga_tercatat,
       ps.id AS pesanan
FROM item_pesanan i
JOIN produk pr   ON pr.id = i.id_produk
JOIN pesanan ps  ON ps.id = i.id_pesanan
ORDER BY pr.id, ps.id;

Output yang Dihasilkan:

text
UPDATE 1
     nama      | harga_katalog | harga_tercatat | pesanan 
---------------+---------------+----------------+---------
 Buku Tulis    |       5000.00 |        4500.00 |     101
 Pulpen Hitam  |       3000.00 |        3000.00 |     101
 Kertas HVS A4 |      55000.00 |       55000.00 |     102
(3 rows)

Baris pertama hasil query adalah bukti polanya bekerja. Harga katalog Buku Tulis kini 5000.00 setelah UPDATE, tetapi pesanan 101 tetap tercatat membayar 4500.00 — nilai yang dibekukan pada saat transaksi. Dua baris lainnya menunjukkan harga yang belum berubah, tempat katalog dan snapshot memang masih sama. Tanpa kolom snapshot, laporan lama akan dihitung ulang memakai harga baru setiap kali harga katalog naik — rekapitulasi bulan Maret berubah angkanya di bulan April, dan tidak ada yang tahu mana yang benar.

Menolak Pasangan Ganda #

Composite primary key menegakkan aturan “satu baris per pasangan”. Operator yang menambahkan produk yang sama dua kali ke satu pesanan akan dihentikan server:

-- Operator menambahkan produk yang sama dua kali pada satu pesanan:
-- composite primary key menolak pasangan yang sudah tercatat.
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (101, 1, 3, 4500.00);

Output yang Dihasilkan:

text
psql:duplikat-baris.sql:4: ERROR:  duplicate key value violates unique constraint "item_pesanan_pkey"
DETAIL:  Key (id_pesanan, id_produk)=(101, 1) already exists.

Error duplicate key value violates unique constraint "item_pesanan_pkey" menyebut pasangan kunci yang bentrok: Key (id_pesanan, id_produk)=(101, 1) already exists. Penolakan ini menjaga agregasi tetap jujur — tanpanya, dua baris (101, 1) dengan kuantitas 10 dan 3 akan dijumlahkan menjadi 13 oleh SUM(kuantitas) padahal maksud operator hanyalah menambah 3 ke yang sudah ada.

Keputusan Keunikan: Ketat atau Longgar #

“Aturan satu baris per pasangan” sebenarnya pilihan, bukan hukum alam. Toko Maju Jaya bisa saja menjual Buku Tulis dengan dua harga berbeda dalam satu pesanan — sepuluh unit harga normal dan dua unit harga promo. Desain ketat menolaknya; desain longgar mengizinkannya dengan melonggarkan keunikan:

-- Variasi desain: produk yang sama boleh muncul dua kali dalam satu
-- pesanan ASAL harganya berbeda (misalnya harga promo dan harga normal).
-- Composite primary key diganti id buatan + UNIQUE yang menyertakan harga.
CREATE TABLE item_pesanan_promo (
    id                   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_pesanan           integer NOT NULL REFERENCES pesanan (id),
    id_produk            integer NOT NULL REFERENCES produk (id),
    kuantitas            integer NOT NULL CHECK (kuantitas > 0),
    harga_saat_transaksi numeric(12, 2) NOT NULL,
    UNIQUE (id_pesanan, id_produk, harga_saat_transaksi)
);

INSERT INTO item_pesanan_promo (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
    (101, 1, 10, 4500.00),
    (101, 1,  2, 4000.00);

SELECT id_pesanan, id_produk, kuantitas, harga_saat_transaksi
FROM item_pesanan_promo
ORDER BY id_pesanan, id_produk, harga_saat_transaksi;

-- Konsekuensi: agregasi HARUS memakai kuantitas,
-- karena jumlah baris tidak lagi sama dengan jumlah jenis produk.
SELECT id_pesanan, SUM(kuantitas) AS total_unit
FROM item_pesanan_promo
GROUP BY id_pesanan
ORDER BY id_pesanan;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id_pesanan | id_produk | kuantitas | harga_saat_transaksi 
------------+-----------+-----------+----------------------
        101 |         1 |         2 |              4000.00
        101 |         1 |        10 |              4500.00
(2 rows)

 id_pesanan | total_unit 
------------+------------
        101 |         12
(1 row)

Perubahan strukturalnya ada tiga dan konsekuensinya nyata:

  1. id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY — identitas baris kini id buatan, karena pasangan (101, 1) boleh muncul dua kali.
  2. UNIQUE (id_pesanan, id_produk, harga_saat_transaksi) — keunikan bergeser ke pasangan-plus-harga: kombinasi yang sama persis tetap ditolak, seperti dibuktikan percobaan berikutnya.
  3. Agregasi harus memakai kuantitas — query penutup menghitung 12 unit untuk pesanan 101 dari dua baris (10 + 2). Menghitung COUNT(*) sebagai “jumlah jenis produk” atau mengalikan per baris lalu menjumlah tetap benar; yang berbahaya adalah asumsi lama bahwa jumlah baris sama dengan jumlah pasangan unik.

Batas dari keunikan longgar tetap dijaga server. Pasangan yang sama persis — pesanan, produk, dan harga yang identik — tetap ditolak:

-- Pasangan (pesanan, produk, harga) yang sama persis tetap ditolak:
-- keunikan longgar punya batas.
INSERT INTO item_pesanan_promo (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (101, 1, 1, 4500.00);

Output yang Dihasilkan:

text
psql:promo-duplikat.sql:4: ERROR:  duplicate key value violates unique constraint "item_pesanan_promo_id_pesanan_id_produk_harga_saat_transaks_key"
DETAIL:  Key (id_pesanan, id_produk, harga_saat_transaksi)=(101, 1, 4500.00) already exists.

Kali ini nama constraint otomatisnya panjang — item_pesanan_promo_id_pesanan_id_produk_harga_saat_transaks_key — karena PostgreSQL merangkai seluruh kolom unique ke dalam namanya. Dua desain ini layak diringkaskan berdampingan:

PertimbanganDesain ketat: PRIMARY KEY (pasangan)Desain longgar: id + UNIQUE (pasangan, harga)
baris ganda per pasanganditolak serverdiizinkan bila harga berbeda
agregasi sederhanaCOUNT(*) = jumlah pasanganwajib agregasi via kuantitas
kasus cocokkebanyakan relasi M:Nperistiwa dengan harga/kondisi berbeda
risiko tersembunyitidak adalaporan terhitung ganda bila asumsi baris dilanggar

Konvensi Penamaan Junction Table #

Nama junction table sebaiknya terbaca sebagai hubungan, bukan sekadar gabungan dua tabel. Tiga gaya yang lazim:

Gaya penamaanContohKapan cocok
nama peranitem_pesanan, peran, kunjunganjunction punya atribut berarti; junction adalah entitas nyata
gabungan terurutpesanan_produk, siswa_mata_kuliahjunction murni penghubung tanpa atribut
kata kerja hubunganmengikuti, menyukai, menggunakanrelasi biner sederhana ala media sosial

Gaya pertama yang dipakai skema kita: item_pesanan dibaca sebagai “item dari pesanan” dan memang membawa atributnya sendiri (kuantitas, harga snapshot). Aturan praktisnya: bila Anda kesulitan menamai junction dengan kata benda yang bermakna, junction itu mungkin masih layak memakai gaya gabungan; bila namanya langsung terpikir — peran, kunjungan, nilai — itu pertanda relasi yang memang sudah layak menjadi entitas.

Dengan seluruh relasi kini berumah — satu-ke-satu, satu-ke-banyak, dan banyak-ke-banyak lewat junction — skema Toko Maju Jaya tampak lengkap. Namun satu pertanyaan belum pernah diajukan sejak chapter pertama: dari semua kolom yang bisa disimpan, mana yang seharusnya tidak disimpan sama sekali? Itulah pertanyaan normalisasi, dan chapter penutup rangkaian relasi ini membukanya lewat tabel datar yang tampak praktis tapi menyimpan tiga anomali siap meledak.

Referensi #

Berkas Sumber

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

buat-junction.sql

Unduh berkas
sql
-- Junction table lengkap: dua foreign key, composite primary key,
-- plus atribut milik relasi itu sendiri (kuantitas, harga saat transaksi).
CREATE TABLE pelanggan (
    id    integer PRIMARY KEY,
    nama  text NOT NULL
);

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

CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    harga numeric(12, 2) NOT NULL
);

CREATE TABLE item_pesanan (
    id_pesanan           integer NOT NULL REFERENCES pesanan (id) ON DELETE CASCADE,
    id_produk            integer NOT NULL REFERENCES produk (id),
    kuantitas            integer NOT NULL CHECK (kuantitas > 0),
    harga_saat_transaksi numeric(12, 2) NOT NULL,
    PRIMARY KEY (id_pesanan, id_produk)
);

INSERT INTO pelanggan (id, nama) VALUES
    (1, 'Budi Santoso'),
    (2, 'Siti Rahayu');

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

INSERT INTO pesanan (id, tanggal, pelanggan_id) VALUES
    (101, DATE '2026-03-01', 1),
    (102, DATE '2026-03-05', 2);

INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
    (101, 1, 10,  4500.00),
    (101, 2,  5,  3000.00),
    (102, 3,  1, 55000.00);

-- Menyusuri "berlian": pesanan -> item -> produk (plus pelanggan).
SELECT ps.id AS pesanan, ps.tanggal, pl.nama AS pelanggan,
       pr.nama AS produk, i.kuantitas, i.harga_saat_transaksi
FROM pesanan ps
JOIN pelanggan pl   ON pl.id = ps.pelanggan_id
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;

-- Total per pesanan dihitung dari junction, bukan disimpan tebak-tebakan.
SELECT ps.id AS pesanan,
       SUM(i.kuantitas * i.harga_saat_transaksi) AS total
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id
ORDER BY ps.id;

duplikat-baris.sql

Unduh berkas
sql
-- Operator menambahkan produk yang sama dua kali pada satu pesanan:
-- composite primary key menolak pasangan yang sudah tercatat.
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (101, 1, 3, 4500.00);

promo-duplikat.sql

Unduh berkas
sql
-- Pasangan (pesanan, produk, harga) yang sama persis tetap ditolak:
-- keunikan longgar punya batas.
INSERT INTO item_pesanan_promo (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (101, 1, 1, 4500.00);

snapshot-harga.sql

Unduh berkas
sql
-- Waktu berjalan: harga Buku Tulis naik di katalog produk.
UPDATE produk SET harga = 5000.00 WHERE id = 1;

-- Pesanan lama tidak ikut berubah: harga_saat_transaksi adalah
-- foto harga pada saat transaksi terjadi, bukan harga hari ini.
SELECT pr.nama,
       pr.harga AS harga_katalog,
       i.harga_saat_transaksi AS harga_tercatat,
       ps.id AS pesanan
FROM item_pesanan i
JOIN produk pr   ON pr.id = i.id_produk
JOIN pesanan ps  ON ps.id = i.id_pesanan
ORDER BY pr.id, ps.id;

variasi-unik.sql

Unduh berkas
sql
-- Variasi desain: produk yang sama boleh muncul dua kali dalam satu
-- pesanan ASAL harganya berbeda (misalnya harga promo dan harga normal).
-- Composite primary key diganti id buatan + UNIQUE yang menyertakan harga.
CREATE TABLE item_pesanan_promo (
    id                   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    id_pesanan           integer NOT NULL REFERENCES pesanan (id),
    id_produk            integer NOT NULL REFERENCES produk (id),
    kuantitas            integer NOT NULL CHECK (kuantitas > 0),
    harga_saat_transaksi numeric(12, 2) NOT NULL,
    UNIQUE (id_pesanan, id_produk, harga_saat_transaksi)
);

INSERT INTO item_pesanan_promo (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
    (101, 1, 10, 4500.00),
    (101, 1,  2, 4000.00);

SELECT id_pesanan, id_produk, kuantitas, harga_saat_transaksi
FROM item_pesanan_promo
ORDER BY id_pesanan, id_produk, harga_saat_transaksi;

-- Konsekuensi: agregasi HARUS memakai kuantitas,
-- karena jumlah baris tidak lagi sama dengan jumlah jenis produk.
SELECT id_pesanan, SUM(kuantitas) AS total_unit
FROM item_pesanan_promo
GROUP BY id_pesanan
ORDER BY id_pesanan;