Anak tangga pertama normalisasi menargetkan pelanggaran yang paling mudah dilihat mata: satu sel yang memuat banyak nilai. Bentuk normal pertama (First Normal Form, 1NF) hanya menuntut satu hal — setiap kolom menyimpan nilai atomik — tetapi tuntutan sederhana itu menentukan apa saja yang bisa dilakukan database atas kolom tersebut. Kolom nama_produk berisi teks "Gergaji Tangan, Ampul LED 12W" memang terbaca manusia, namun database tidak bisa menghitungnya, tidak bisa mengindeksnya secara berguna, dan tidak bisa menjaga keunikan isinya. Chapter ini membangun tabel pelanggar 1NF di belajar_rdb, membuktikan kerugiannya lewat query nyata, lalu memperbaikinya menjadi baris terpisah — sebelum/sesudah yang bisa diperbandingkan langsung.

Aturan 1NF: Satu Kolom, Satu Nilai #

Nilai atomik adalah nilai yang tidak lagi bisa dipecah secara berguna untuk kebutuhan aplikasi. Nama pelanggan "Budi Santoso" tetap atomik meskipun terdiri dari dua kata, karena aplikasi memperlakukannya sebagai satu kesatuan identitas. Daftar produk terpisah koma jelas bukan atomik: satu sel memuat banyak kejadian yang masing-masing layak dihitung, dicari, dan dirujuk. Kelompok kolom berulang seperti telepon_1, telepon_2, telepon_3 adalah pelanggaran saudaranya: banyaknya nilai tetap banyak, hanya dipindah ke banyak kolom — dan jumlah kolom menjadi batas keras jumlah datanya. Kedua bentuk pelanggaran ini yang akan kita bedah.

Tabel Pelanggar: Daftar di Dalam Sel #

Toko Maju Jaya kembali menjadi bahan uji. Kasir mencatat isi pesanan sebagai satu teks daftar di kolom daftar_produk:

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

CREATE TABLE pesanan_flat (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama_pelanggan VARCHAR(50) NOT NULL,
  tanggal DATE NOT NULL,
  daftar_produk VARCHAR(200) NOT NULL,
  total DECIMAL(12,2) NOT NULL
);

INSERT INTO pesanan_flat (nama_pelanggan, tanggal, daftar_produk, total) VALUES
  ('Budi Santoso', '2026-01-05', 'Obeng Set 6pcs, Ampul LED 12W', 60000.00),
  ('Siti Rahayu',  '2026-01-07', 'Gergaji Tangan, Tang Kombinasi 8in, Ampul LED 12W', 168000.00),
  ('Andi Wijaya',  '2026-01-15', 'Bor Listrik 500W', 485000.00),
  ('Rina Melati',  '2026-01-08', 'Tang Kombinasi 8in, Obeng Set 6pcs', 83000.00);

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, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0
sql
SELECT * FROM pesanan_flat ORDER BY id;

Output yang Dihasilkan:

text
+----+----------------+------------+---------------------------------------------------+-----------+
| id | nama_pelanggan | tanggal    | daftar_produk                                     | total     |
+----+----------------+------------+---------------------------------------------------+-----------+
|  1 | Budi Santoso   | 2026-01-05 | Obeng Set 6pcs, Ampul LED 12W                     |  60000.00 |
|  2 | Siti Rahayu    | 2026-01-07 | Gergaji Tangan, Tang Kombinasi 8in, Ampul LED 12W | 168000.00 |
|  3 | Andi Wijaya    | 2026-01-15 | Bor Listrik 500W                                  | 485000.00 |
|  4 | Rina Melati    | 2026-01-08 | Tang Kombinasi 8in, Obeng Set 6pcs                |  83000.00 |
+----+----------------+------------+---------------------------------------------------+-----------+
4 rows in set (0.00 sec)

Kenapa Daftar Merugikan Query #

Pertanyaan paling sederhana toko — “pesanan mana yang memuat Ampul LED 12W?” — harus dijawab dengan pencarian substring:

sql
SELECT id, nama_pelanggan, daftar_produk
FROM pesanan_flat
WHERE daftar_produk LIKE '%Ampul LED 12W%'
ORDER BY id;

Output yang Dihasilkan:

text
+----+----------------+---------------------------------------------------+
| id | nama_pelanggan | daftar_produk                                     |
+----+----------------+---------------------------------------------------+
|  1 | Budi Santoso   | Obeng Set 6pcs, Ampul LED 12W                     |
|  2 | Siti Rahayu    | Gergaji Tangan, Tang Kombinasi 8in, Ampul LED 12W |
+----+----------------+---------------------------------------------------+
2 rows in set (0.00 sec)

Query di atas berhasil mengembalikan dua baris, tetapi tiga masalah tersembunyi di baliknya. Pertama, pola LIKE '%...%' yang diawali wildcard tidak bisa memanfaatkan index B-tree pada kolom tersebut — index bekerja seperti buku telepon yang diurutkan dari huruf awal, dan pencarian “berisi di tengah” memaksa server memindai isi seluruh baris satu per satu; pada tabel jutaan baris, itu perbedaan antara milidetik dan menit. Kedua, substring cocok secara buta: produk bernama Ampul LED 12W Pro atau Ampul LED 12W-Paket ikut terhitung sebagai kecocokan. Ketiga, agregasi mustahil: “berapa unit Ampul LED 12W terjual bulan ini?” tidak punya jawaban karena unit barang tidak pernah menjadi baris yang bisa di-COUNT — kita hanya punya teks bebas yang formatnya bergantung kebiasaan kasir.

Perbaikan: Satu Baris per Produk #

Perbaikan 1NF memindahkan isi keranjang dari satu sel menjadi banyak baris pada tabel anak pesanan_barang, dengan tabel pesanan menyimpan data transaksi level pesanan:

sql
CREATE TABLE pesanan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama_pelanggan VARCHAR(50) NOT NULL,
  tanggal DATE NOT NULL,
  total DECIMAL(12,2) NOT NULL
);

CREATE TABLE pesanan_barang (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pesanan_id INT NOT NULL,
  nama_produk VARCHAR(50) NOT NULL,
  harga DECIMAL(10,2) NOT NULL,
  CONSTRAINT fk_barang_pesanan
    FOREIGN KEY (pesanan_id) REFERENCES pesanan(id)
);

INSERT INTO pesanan (nama_pelanggan, tanggal, total) VALUES
  ('Budi Santoso', '2026-01-05', 60000.00),
  ('Siti Rahayu',  '2026-01-07', 168000.00),
  ('Andi Wijaya',  '2026-01-15', 485000.00),
  ('Rina Melati',  '2026-01-08', 83000.00);

INSERT INTO pesanan_barang (pesanan_id, nama_produk, harga) VALUES
  (1, 'Obeng Set 6pcs', 35000.00),
  (1, 'Ampul LED 12W', 25000.00),
  (2, 'Gergaji Tangan', 95000.00),
  (2, 'Tang Kombinasi 8in', 48000.00),
  (2, 'Ampul LED 12W', 25000.00),
  (3, 'Bor Listrik 500W', 485000.00),
  (4, 'Tang Kombinasi 8in', 48000.00),
  (4, 'Obeng Set 6pcs', 35000.00);

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 0 rows affected (0.00 sec)

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

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

Delapan baris pesanan_barang memuat informasi yang sama dengan empat teks daftar, tetapi kini setiap produk berdiri sebagai baris dengan pesanan_id penunjuk induknya. Mari kita buktikan query yang tadinya mustahil:

sql
SELECT nama_produk,
  COUNT(*) AS jumlah_terjual,
  SUM(harga) AS total_penjualan
FROM pesanan_barang
GROUP BY nama_produk
ORDER BY nama_produk;

Output yang Dihasilkan:

text
+--------------------+----------------+-----------------+
| nama_produk        | jumlah_terjual | total_penjualan |
+--------------------+----------------+-----------------+
| Ampul LED 12W      |              2 |        50000.00 |
| Bor Listrik 500W   |              1 |       485000.00 |
| Gergaji Tangan     |              1 |        95000.00 |
| Obeng Set 6pcs     |              2 |        70000.00 |
| Tang Kombinasi 8in |              2 |        96000.00 |
+--------------------+----------------+-----------------+
5 rows in set (0.00 sec)

Laporan penjualan per produk kini menjadi GROUP BY biasa: Ampul LED 12W tercatat terjual dua unit dan setiap produk punya angka pasti. Pencarian produk juga turun ke perbandingan persis nama_produk = 'Ampul LED 12W' — bentuk equality yang bisa dilayani index. Rincian per pesanan tetap utuh digabungkan lewat JOIN:

sql
SELECT p.id, p.nama_pelanggan, b.nama_produk, b.harga
FROM pesanan p
JOIN pesanan_barang b ON b.pesanan_id = p.id
ORDER BY p.id, b.id;

Output yang Dihasilkan:

text
+----+----------------+--------------------+-----------+
| id | nama_pelanggan | nama_produk        | harga     |
+----+----------------+--------------------+-----------+
|  1 | Budi Santoso   | Obeng Set 6pcs     |  35000.00 |
|  1 | Budi Santoso   | Ampul LED 12W      |  25000.00 |
|  2 | Siti Rahayu    | Gergaji Tangan     |  95000.00 |
|  2 | Siti Rahayu    | Tang Kombinasi 8in |  48000.00 |
|  2 | Siti Rahayu    | Ampul LED 12W      |  25000.00 |
|  3 | Andi Wijaya    | Bor Listrik 500W   | 485000.00 |
|  4 | Rina Melati    | Tang Kombinasi 8in |  48000.00 |
|  4 | Rina Melati    | Obeng Set 6pcs     |  35000.00 |
+----+----------------+--------------------+-----------+
8 rows in set (0.00 sec)

Kolom Berulang Menjadi Tabel Anak #

Bentuk pelanggar kedua: pelanggan dengan beberapa nomor telepon, ditampung tiga kolom bernomor.

sql
CREATE TABLE pelanggan_lama (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(50) NOT NULL,
  telepon_1 VARCHAR(15) NULL,
  telepon_2 VARCHAR(15) NULL,
  telepon_3 VARCHAR(15) NULL
);

INSERT INTO pelanggan_lama (nama, telepon_1, telepon_2, telepon_3) VALUES
  ('Budi Santoso', '021-5550111', '0812-1000001', NULL),
  ('Siti Rahayu', '022-5550122', NULL, NULL),
  ('Andi Wijaya', '031-5550133', '0813-1000002', '0815-1000003');

SELECT * FROM pelanggan_lama ORDER BY id;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

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

+----+--------------+-------------+--------------+--------------+
| id | nama         | telepon_1   | telepon_2    | telepon_3    |
+----+--------------+-------------+--------------+--------------+
|  1 | Budi Santoso | 021-5550111 | 0812-1000001 | NULL         |
|  2 | Siti Rahayu  | 022-5550122 | NULL         | NULL         |
|  3 | Andi Wijaya  | 031-5550133 | 0813-1000002 | 0815-1000003 |
+----+--------------+-------------+--------------+--------------+
3 rows in set (0.00 sec)

Tiga masalah lama langsung terlihat kembali dalam kostum baru: pelanggan dengan nomor keempat menuntut ALTER TABLE menambah kolom; pencarian “siapa pemilik 0812-1000001” harus memeriksa telepon_1 OR telepon_2 OR telepon_3; dan constraint UNIQUE tidak bisa mencegah nomor yang sama terpakai dua pelanggan berbeda karena nilainya tersebar di kolom berbeda. Perbaikannya sama seperti daftar produk — nomor menjadi baris tabel anak kontak:

sql
CREATE TABLE kontak (
  id INT AUTO_INCREMENT PRIMARY KEY,
  pelanggan_id INT NOT NULL,
  nomor VARCHAR(15) NOT NULL,
  CONSTRAINT fk_kontak_pelanggan
    FOREIGN KEY (pelanggan_id) REFERENCES pelanggan_lama(id)
);

INSERT INTO kontak (pelanggan_id, nomor) VALUES
  (1, '021-5550111'),
  (1, '0812-1000001'),
  (2, '022-5550122'),
  (3, '031-5550133'),
  (3, '0813-1000002'),
  (3, '0815-1000003');

SELECT p.nama, k.nomor
FROM pelanggan_lama p
LEFT JOIN kontak k ON k.pelanggan_id = p.id
ORDER BY p.id, k.id;

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

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

+--------------+--------------+
| nama         | nomor        |
+--------------+--------------+
| Budi Santoso | 021-5550111  |
| Budi Santoso | 0812-1000001 |
| Siti Rahayu  | 022-5550122  |
| Andi Wijaya  | 031-5550133  |
| Andi Wijaya  | 0813-1000002 |
| Andi Wijaya  | 0815-1000003 |
+--------------+--------------+
6 rows in set (0.00 sec)

Nomor keempat Budi kini hanya sebuah INSERT baru; pencarian pemilik nomor menjadi satu kondisi k.nomor = '0812-1000001'; dan UNIQUE pada kolom nomor (bila diperlukan) kini bisa ditegakkan karena semua nilai hidup di satu kolom. LEFT JOIN dipakai supaya pelanggan tanpa nomor tetap tampil — dalam data contoh kita semua pelanggan punya kontak, tetapi pola ini siap untuk pelanggan baru yang datanya belum lengkap.

Membersihkan Database Latihan #

sql
DROP DATABASE belajar_rdb;

Output yang Dihasilkan:

text
Query OK, 5 rows affected (0.00 sec)

Referensi #

First Normal Form tuntas dibedah: daftar dalam sel dan kolom bernomor sama-sama kalah oleh baris-baris tabel anak, dan sebagai bonus kita mendapat query yang bisa dihitung, dicari persis, dan dilayani index. Namun memindahkan daftar ke baris belum menuntaskan kebersihan data — nama pelanggan masih tersimpan berulang di setiap baris pesanan, dan harga produk yang sama berulang di banyak baris pesanan_barang. Pelanggaran berikutnya tidak lagi bersembunyi di dalam satu sel, melainkan pada hubungan antarkolom dan kunci — khususnya ketika primary key-nya komposit. Chapter berikutnya naik ke Second Normal Form, tempat ketergantungan parsial dibongkar.