Relasi satu-ke-banyak (one-to-many) adalah bentuk hubungan yang paling sering muncul di aplikasi: satu pelanggan membuat banyak pesanan, satu kategori memuat banyak produk, satu proyek berisi banyak tugas. Karena frekuensinya, cara membangun relasi ini menjadi keahlian paling mendasar dalam merancang skema — dan kesalahan paling klasik pemula justru terjadi di sini: menampung “banyak” itu sebagai kolom-kolom terpisah di tabel induk. Chapter ini membedah kesalahan tersebut secara nyata, lalu membangun desain yang benar dari CREATE TABLE sampai JOIN. Aturan akhirnya bisa diringkas satu kalimat: foreign key selalu ditempatkan di tabel anak.

Konsep Satu-ke-Banyak #

Relasi satu-ke-banyak menyatakan bahwa satu baris pada tabel induk boleh berpasangan dengan banyak baris pada tabel anak, sementara satu baris anak hanya boleh menunjuk tepat satu baris induk. Satu pelanggan boleh membuat puluhan pesanan, tetapi satu pesanan hanya dimiliki satu pelanggan. Kata kuncinya ada pada pemilihan sisi: tabel yang berperan “banyak” (pesanan) yang menyimpan kolom penunjuk pelanggan_id, bukan sebaliknya.

Alasannya murni mekanis. Kolom penunjuk (foreign key) pada satu baris hanya sanggup menyimpan satu nilai — satu baris pesanan cukup menyimpan satu pelanggan_id, dan banyak baris pesanan boleh mengulang nilai yang sama sehingga terkumpul banyak anak untuk satu induk. Sebaliknya, kalau pelanggan yang menyimpan pesanan_id, satu kolom hanya menampung satu pesanan; untuk “banyak” kita terpaksa menambah kolom demi kolom. Perhatikan bahwa arah ini persis kebalikan relasi satu-ke-satu pada chapter sebelumnya: di sana kolom penghubung bisa berada di kedua sisi, sedangkan di sisi satu-ke-banyak penempatannya hanya satu yang benar.

Anti-Pattern: Menampung Pesanan di Kolom Terpisah #

Mari kita lihat bagaimana rupa desain yang salah ketika “banyak” dipaksa masuk ke tabel induk. Tabel berikut menyiapkan tiga pasang kolom untuk tiga pesanan terakhir tiap pelanggan:

sql
DROP DATABASE IF EXISTS belajar_rdb;
CREATE DATABASE belajar_rdb;
USE belajar_rdb;

CREATE TABLE pelanggan_lama (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  tanggal_pesanan_1 DATE NULL,
  total_pesanan_1 DECIMAL(12,2) NULL,
  tanggal_pesanan_2 DATE NULL,
  total_pesanan_2 DECIMAL(12,2) NULL,
  tanggal_pesanan_3 DATE NULL,
  total_pesanan_3 DECIMAL(12,2) NULL
);

INSERT INTO pelanggan_lama (nama, tanggal_pesanan_1, total_pesanan_1,
  tanggal_pesanan_2, total_pesanan_2, tanggal_pesanan_3, total_pesanan_3) VALUES
  ('Budi Santoso', '2026-01-05', 170000.00, '2026-01-10', 62000.00, '2026-01-12', 15000.00),
  ('Siti Rahayu',  '2026-01-07',  50000.00, '2026-01-18', 85000.00, NULL, NULL),
  ('Andi Wijaya',  '2026-01-15', 137000.00, NULL, NULL, NULL, NULL);

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
Query OK, 0 rows affected (0.00 sec)

Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0
sql
SELECT * FROM pelanggan_lama ORDER BY id;

Output yang Dihasilkan:

text
+----+--------------+-------------------+-----------------+-------------------+-----------------+-------------------+-----------------+
| id | nama         | tanggal_pesanan_1 | total_pesanan_1 | tanggal_pesanan_2 | total_pesanan_2 | tanggal_pesanan_3 | total_pesanan_3 |
+----+--------------+-------------------+-----------------+-------------------+-----------------+-------------------+-----------------+
|  1 | Budi Santoso | 2026-01-05        |       170000.00 | 2026-01-10        |        62000.00 | 2026-01-12        |        15000.00 |
|  2 | Siti Rahayu  | 2026-01-07        |        50000.00 | 2026-01-18        |        85000.00 | NULL              |            NULL |
|  3 | Andi Wijaya  | 2026-01-15        |       137000.00 | NULL              |            NULL | NULL              |            NULL |
+----+--------------+-------------------+-----------------+-------------------+-----------------+-------------------+-----------------+
3 rows in set (0.00 sec)

Tabel di atas terlihat berhasil, tetapi kerusakannya menunggu di beberapa sudut. Pertama, total belanja tiap pelanggan harus dihitung dengan menjumlahkan kolom satu per satu — agregat SUM tidak bisa ikut bekerja:

sql
SELECT nama,
  total_pesanan_1 + total_pesanan_2 + total_pesanan_3 AS total_belanja
FROM pelanggan_lama
ORDER BY id;

Output yang Dihasilkan:

text
+--------------+---------------+
| nama         | total_belanja |
+--------------+---------------+
| Budi Santoso |     247000.00 |
| Siti Rahayu  |          NULL |
| Andi Wijaya  |          NULL |
+--------------+---------------+
3 rows in set (0.00 sec)

Perhatikan hasilnya: total Budi benar karena ketiga pasang kolomnya terisi, tetapi total Siti dan Andi terhitung NULL — propagasi NULL pada penjumlahan membuat satu kolom kosong meracuni seluruh hasil. Masalah kedua lebih fatal: ketika Budi membuat pesanan keempat, tidak ada kolom tanggal_pesanan_4 yang tersedia; kita harus mengubah struktur tabel setiap kali batas kolom terlampaui. Masalah ketiga: pertanyaan sederhana “pesanan mana saja yang terjadi pada 2026-01-10?” harus memeriksa tiga kolom sekaligus dengan OR, dan pertanyaan “tampilkan pesanan terbesar” tidak bisa dijawab ORDER BY ... LIMIT karena nilainya tersebar mendatar. Pola kolom berulang seperti ini adalah anti-pattern: struktur tabel dipaksa mengikuti jumlah data, padahal jumlah data itu yang berubah setiap hari.

Praktik: Skema pelanggan dan pesanan #

Perbaikannya membalik arah penyimpanan: data pesanan keluar dari tabel pelanggan menjadi baris-baris tabel pesanan, dan setiap baris menyimpan satu pelanggan_id sebagai penunjuk induknya. Kita bangun ulang keduanya:

sql
CREATE TABLE pelanggan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  kota VARCHAR(30) NOT NULL
);

CREATE TABLE pesanan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  tanggal DATE NOT NULL,
  total DECIMAL(12,2) NOT NULL,
  CONSTRAINT fk_pesanan_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

Empat keputusan penting ada pada struktur di atas:

  1. pelanggan kehilangan semua kolom pesanan — tabel hanya menyimpan identitas; lebar tabel tidak lagi bergantung pada jumlah transaksi.
  2. pelanggan_id INT NOT NULL di tabel anak — setiap baris pesanan wajib menyimpan satu penunjuk induk; kolom inilah foreign key-nya.
  3. CONSTRAINT fk_pesanan_pelanggan FOREIGN KEY ... — server kini menolak baris pesanan yang menunjuk pelanggan yang tidak ada; constraint diberi nama agar pesan galat mudah dikenali.
  4. id tetap AUTO_INCREMENT pada kedua tabel — identitas baris berdiri sendiri di tiap tabel, terlepas dari relasi.

Kita isi data yang sama seperti versi lama, lalu menambah dua pelanggan baru — Rina dengan satu pesanan, dan Dewi yang belum pernah memesan sama sekali:

sql
INSERT INTO pelanggan (nama, kota) VALUES
  ('Budi Santoso', 'Jakarta'),
  ('Siti Rahayu',  'Bandung'),
  ('Andi Wijaya',  'Surabaya'),
  ('Rina Melati',  'Yogyakarta'),
  ('Dewi Lestari', 'Bandung');

INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES
  (1, '2026-01-05', 170000.00),
  (1, '2026-01-10',  62000.00),
  (1, '2026-01-12',  15000.00),
  (2, '2026-01-07',  50000.00),
  (2, '2026-01-18',  85000.00),
  (3, '2026-01-15', 137000.00),
  (4, '2026-01-08', 125000.00);

Output yang Dihasilkan:

text
Query OK, 5 rows affected (0.00 sec)
Records: 5  Duplicates: 0  Warnings: 0

Query OK, 7 rows affected (0.00 sec)
Records: 7  Duplicates: 0  Warnings: 0

Ketiga pesanan Budi kini menempati tiga baris dengan pelanggan_id yang sama — nilai 1 diulang tiga kali, dan itulah cara tabel anak menyatakan “anak-anak ini satu keluarga”. Pesanan keempat Budi kelak cukup menjadi baris baru tanpa perubahan struktur. Penegakan relasi juga langsung terasa: menyisipkan pesanan untuk pelanggan nomor 99 ditolak server:

sql
-- @error
INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES (99, '2026-01-25', 50000.00);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`belajar_rdb`.`pesanan`, CONSTRAINT `fk_pesanan_pelanggan` FOREIGN KEY (`pelanggan_id`) REFERENCES `pelanggan` (`id`))
Bye

Menggabungkan Induk dan Anak #

Data yang terpisah digabung kembali dengan JOIN pada pasangan kunci pelanggan.id = pesanan.pelanggan_id:

sql
SELECT p.nama, pn.tanggal, pn.total
FROM pelanggan p
INNER JOIN pesanan pn ON p.id = pn.pelanggan_id
ORDER BY pn.id;

Output yang Dihasilkan:

text
+--------------+------------+-----------+
| nama         | tanggal    | total     |
+--------------+------------+-----------+
| Budi Santoso | 2026-01-05 | 170000.00 |
| Budi Santoso | 2026-01-10 |  62000.00 |
| Budi Santoso | 2026-01-12 |  15000.00 |
| Siti Rahayu  | 2026-01-07 |  50000.00 |
| Siti Rahayu  | 2026-01-18 |  85000.00 |
| Andi Wijaya  | 2026-01-15 | 137000.00 |
| Rina Melati  | 2026-01-08 | 125000.00 |
+--------------+------------+-----------+
7 rows in set (0.00 sec)

INNER JOIN menghasilkan tujuh baris — satu baris per pesanan, termasuk ketiga pesanan Budi yang kini bisa tampil penuh. Daftar lengkap “semua pelanggan beserta pesanannya” memakai LEFT JOIN supaya Dewi tetap tampil:

sql
SELECT p.nama, pn.tanggal, pn.total
FROM pelanggan p
LEFT JOIN pesanan pn ON p.id = pn.pelanggan_id
ORDER BY p.id, pn.id;

Output yang Dihasilkan:

text
+--------------+------------+-----------+
| nama         | tanggal    | total     |
+--------------+------------+-----------+
| Budi Santoso | 2026-01-05 | 170000.00 |
| Budi Santoso | 2026-01-10 |  62000.00 |
| Budi Santoso | 2026-01-12 |  15000.00 |
| Siti Rahayu  | 2026-01-07 |  50000.00 |
| Siti Rahayu  | 2026-01-18 |  85000.00 |
| Andi Wijaya  | 2026-01-15 | 137000.00 |
| Rina Melati  | 2026-01-08 | 125000.00 |
| Dewi Lestari | NULL       |      NULL |
+--------------+------------+-----------+
8 rows in set (0.00 sec)

Baris Dewi muncul dengan kolom pesanan NULL — satu-satunya penanda bahwa pelanggan tersebut memang belum punya transaksi, bukan hilang dari laporan. Terakhir, laporan agregat yang tadinya mustahil kini menjadi query biasa:

sql
SELECT p.nama,
  COUNT(pn.id) AS jumlah_pesanan,
  SUM(pn.total) AS total_belanja
FROM pelanggan p
LEFT JOIN pesanan pn ON p.id = pn.pelanggan_id
GROUP BY p.id, p.nama
ORDER BY p.id;

Output yang Dihasilkan:

text
+--------------+----------------+---------------+
| nama         | jumlah_pesanan | total_belanja |
+--------------+----------------+---------------+
| Budi Santoso |              3 |     247000.00 |
| Siti Rahayu  |              2 |     135000.00 |
| Andi Wijaya  |              1 |     137000.00 |
| Rina Melati  |              1 |     125000.00 |
| Dewi Lestari |              0 |          NULL |
+--------------+----------------+---------------+
5 rows in set (0.00 sec)

COUNT(pn.id) menghitung baris anak yang nyata sehingga Dewi bernilai 0, sementara SUM(pn.total) untuk Dewi menghasilkan NULL karena tidak ada baris untuk dijumlahkan — bila laporan menginginkan angka nol, pembungkus IFNULL(SUM(pn.total), 0) menyelesaikannya. Bandingkan dengan versi anti-pattern: penjumlahan manual tiga kolom yang gugur oleh NULL, batas tiga pesanan, dan pencarian yang harus memeriksa banyak kolom. Desain baris-per-anak menyelesaikan ketiganya sekaligus.

Diagram berikut merangkum hubungan kedua tabel — notasi ||--o{ berarti “satu baris induk terhubung nol atau banyak baris anak”:

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_rdb;

Output yang Dihasilkan:

text
Query OK, 3 rows affected (0.00 sec)

Referensi #

Relasi satu-ke-banyak tuntas dari desain sampai query: kolom penunjuk di tabel anak, penegakan lewat foreign key, penggabungan lewat JOIN, dan agregasi yang bekerja untuk berapa pun jumlah anak. Namun dunia tidak berhenti di satu-ke-banyak — seorang mahasiswa mengambil banyak kursus sekaligus satu kursus diambil banyak mahasiswa, dan tidak ada sisi “anak” yang jelas. Chapter berikutnya membahas relasi banyak-ke-banyak dan tabel perantara yang menjembataninya.