Bayangkan menyimpan seluruh data blog — penulis, artikel, kategori, tags — ke dalam satu tabel tunggal. Sekilas terasa praktis: tidak perlu JOIN, tidak perlu berpikir relasi. Masalahnya baru terasa berminggu-minggu kemudian: nama penulis tertulis puluhan kali, satu perubahan email harus dikejar di banyak baris, dan pencarian tag mulai menghasilkan data yang salah. Normalization adalah proses sistematis untuk memperbaiki situasi seperti itu: menata ulang kolom dan tabel sehingga setiap fakta tersimpan tepat di satu tempat. Pada chapter ini kita menormalkan satu tabel berantakan langkah demi langkah — 1NF, 2NF, lalu 3NF — membuktikan setiap anomali dengan kode nyata, dan menutupnya dengan diskusi kapan denormalisasi justru masuk akal.
Tabel Berantakan dan Tiga Anomali #
Berikut tabel articles versi berantakan yang akan menjadi bahan latihan kita. Semua informasi ditumpuk ke satu tempat: data penulis, data kategori, dan daftar tag dipisahkan koma di dalam satu kolom tags.
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
category_name TEXT NOT NULL,
category_slug TEXT NOT NULL,
tags TEXT NOT NULL
)");
$stmt = $pdo->prepare(
"INSERT INTO articles (title, author_name, author_email, category_name, category_slug, tags)
VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->execute(["Belajar PHP", "Budi", "[email protected]", "Pemrograman", "pemrograman", "php, database"]);
$stmt->execute(["Tips MySQL", "Siti", "[email protected]", "Pemrograman", "pemrograman", "mysql, tips"]);
foreach ($pdo->query("SELECT id, title, tags FROM articles") as $row) {
echo $row["id"] . ". " . $row["title"] . " [tags: " . $row["tags"] . "]\n";
}
echo "Artikel dengan tag 'sql':\n";
foreach ($pdo->query("SELECT title FROM articles WHERE tags LIKE '%sql%'") as $row) {
echo "- " . $row["title"] . "\n";
}Output yang Dihasilkan:
1. Belajar PHP [tags: php, database]
2. Tips MySQL [tags: mysql, tips]
Artikel dengan tag 'sql':
- Tips MySQLTabel di atas mengandung tiga jenis anomali klasik:
- Anomali insert: kita tidak bisa mencatat kategori baru yang belum punya satu artikel pun, karena
category_namedancategory_slughanya hidup menempel pada baris artikel. - Anomali update: satu fakta yang sama tersimpan berkali-kali (misalnya
Pemrograman/pemrogramandi dua baris), sehingga perubahan harus dikejar ke semua baris — dan satu baris yang terlewat langsung menciptakan kontradiksi. - Anomali delete: menghapus artikel terakhir dari sebuah kategori ikut menghilangkan jejak kategori tersebut dari database.
Lihat pula hasil pencarian di akhir output: tidak ada satu pun artikel yang benar-benar bertag sql, tetapi query LIKE '%sql%' tetap mengembalikan “Tips MySQL”. Pola %sql% cocok dengan substring di dalam kata mysql, bukan dengan nilai tag yang utuh. Inilah akibat langsung menyimpan banyak nilai di satu kolom: database tidak bisa memperlakukan setiap tag sebagai kesatuan data yang bisa dicari secara persis.
Anomali Update dalam Praktik #
Anomali update paling mudah dibuktikan dengan dua artikel dari penulis yang sama. Skrip berikut menyisipkan dua artikel milik Budi, lalu memperbarui email hanya pada baris pertama:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL
)");
$stmt = $pdo->prepare("INSERT INTO articles (title, author_name, author_email) VALUES (?, ?, ?)");
$stmt->execute(["Belajar PHP", "Budi", "[email protected]"]);
$stmt->execute(["Tips MySQL", "Budi", "[email protected]"]);
// Budi ganti email, tetapi hanya baris pertama yang diperbarui
$pdo->exec("UPDATE articles SET author_email = '[email protected]' WHERE id = 1");
foreach ($pdo->query("SELECT author_name, author_email FROM articles ORDER BY id") as $row) {
echo $row["author_name"] . " <" . $row["author_email"] . ">\n";
}Output yang Dihasilkan:
Budi <[email protected]>
Budi <[email protected]>Setelah skrip berjalan, ada dua alamat email untuk orang yang sama: [email protected] dan [email protected]. Tidak ada cara untuk menjawab “berapa email Budi yang sebenarnya?” tanpa menunggu manusia memutuskan. Email adalah fakta tentang penulis, bukan fakta tentang artikel — dan setiap fakta yang menempel pada entitas yang salah akan selalu berujung pada anomali semacam ini.
Langkah 1 — Bentuk Normal Pertama (1NF) #
Bentuk normal pertama menuntut dua hal sederhana: setiap kolom berisi nilai atomik (tidak bisa dipecah lagi), dan tidak ada kelompok nilai yang diulang dalam satu baris. Kolom tags berisi "php, database" jelas melanggar aturan tersebut karena menyimpan dua nilai sekaligus. Perbaikannya bukan menyulap kolom menjadi lebih pintar, melainkan memindahkan nilai berulang ke tabel tersendiri:
-- Sebelum (melanggar 1NF): banyak nilai dalam satu kolom
tags TEXT NOT NULL -- berisi "php, database"
-- Sesudah (1NF): satu nilai atomik per baris
CREATE TABLE article_tags (
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (article_id, tag)
);Tabel article_tags memakai composite primary key (article_id, tag) — persis pola yang kita pelajari pada chapter Primary dan Foreign Keys
— sehingga pasangan yang sama tidak pernah tersimpan dua kali. Mencari artikel bertag sql kini menjadi perbandingan nilai yang persis (WHERE tag = 'sql'), bukan tebak-tebakan substring. Relasi banyak-ke-banyak antara artikel dan tag pun menjadi eksplisit.
Langkah 2 — Bentuk Normal Kedua (2NF) #
Bentuk normal kedua berlaku pada tabel ber-composite key: setelah 1NF terpenuhi, tidak boleh ada kolom non-key yang hanya bergantung pada sebagian key. Istilahnya partial dependency. Contoh paling terkenal datang dari domain kampus — tabel nilai yang key-nya adalah pasangan siswa dan mata kuliah:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE enrollments (
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
student_name TEXT NOT NULL,
grade TEXT NOT NULL,
PRIMARY KEY (student_id, course_id)
)");
$stmt = $pdo->prepare(
"INSERT INTO enrollments (student_id, course_id, student_name, grade) VALUES (?, ?, ?, ?)"
);
$stmt->execute([1, 101, "Budi", "A"]);
$stmt->execute([1, 102, "Budi", "B"]);
$stmt->execute([2, 101, "Siti", "A"]);
foreach ($pdo->query(
"SELECT student_id, course_id, student_name, grade
FROM enrollments ORDER BY student_id, course_id"
) as $row) {
echo "siswa #{$row['student_id']} ({$row['student_name']})"
. " - matakuliah #{$row['course_id']} - nilai {$row['grade']}\n";
}Output yang Dihasilkan:
siswa #1 (Budi) - matakuliah #101 - nilai A
siswa #1 (Budi) - matakuliah #102 - nilai B
siswa #2 (Siti) - matakuliah #101 - nilai ANama “Budi” tertulis dua kali karena Budi mengambil dua mata kuliah. Mari periksa ketergantungan setiap kolom terhadap composite key:
| Kolom | Bergantung pada | Status |
|---|---|---|
grade | seluruh key: siswa dan mata kuliah | Sah |
student_name | hanya student_id | Partial dependency |
Nilai memang milik pasangan siswa-mata kuliah, tetapi nama hanya milik siswa. Perbaikan 2NF-nya adalah memisahkan fakta yang bergantung parsial ke tabel sendiri: students(student_id, name) dan enrollments(student_id, course_id, grade). Nama siswa kemudian tersimpan tepat satu kali berapa pun mata kuliah yang diambil, dan anomali update pada nama lenyap bersama redundansinya.
Langkah 3 — Bentuk Normal Ketiga (3NF) #
Bentuk normal ketiga menuntut satu langkah lebih jauh: tidak boleh ada transitive dependency, yaitu kolom non-key yang bergantung pada kolom non-key lain. Kandidatnya ada di tabel articles kita: category_slug memang berbeda tiap kategori, tetapi nilai tersebut ditentukan oleh category_name — bukan oleh id artikel itu sendiri.
Rantai id → category_name → category_slug berarti slug hanya menumpang pada artikel. Jika dua kategori berbeda keliru memakai slug yang sama, konflik tersebut tersebar diam-diam di seluruh baris artikel. Perbaikan 3NF memindahkan fakta kategori ke tabel categories(id, name, slug) — dan dengan alasan yang sama, fakta penulis (author_name, author_email) pindah ke authors(id, name, email). Artikel cukup menyimpan author_id dan category_id sebagai foreign key.
Schema Hasil Normalisasi #
Inilah hasil akhir seluruh proses — empat tabel yang masing-masing menyimpan satu jenis fakta:
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("PRAGMA foreign_keys = ON");
$pdo->exec("CREATE TABLE authors (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE
)");
$pdo->exec("CREATE TABLE articles (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author_id INTEGER NOT NULL REFERENCES authors(id),
category_id INTEGER NOT NULL REFERENCES categories(id)
)");
$pdo->exec("CREATE TABLE article_tags (
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (article_id, tag)
)");
$pdo->exec("INSERT INTO authors (name, email) VALUES ('Budi', '[email protected]')");
$pdo->exec("INSERT INTO categories (name, slug) VALUES ('Pemrograman', 'pemrograman')");
$stmt = $pdo->prepare("INSERT INTO articles (title, author_id, category_id) VALUES (?, ?, ?)");
$stmt->execute(["Belajar PHP", 1, 1]);
$stmt = $pdo->prepare("INSERT INTO article_tags (article_id, tag) VALUES (?, ?)");
$stmt->execute([1, "php"]);
$stmt->execute([1, "database"]);
function articlesWithTag(PDO $pdo, string $tag): array
{
$stmt = $pdo->prepare(
"SELECT a.title FROM articles a
JOIN article_tags t ON t.article_id = a.id
WHERE t.tag = ?"
);
$stmt->execute([$tag]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
echo "Tag 'sql': " . json_encode(articlesWithTag($pdo, "sql")) . "\n";
echo "Tag 'php': " . json_encode(articlesWithTag($pdo, "php")) . "\n";
// Budi ganti email: cukup satu baris di satu tabel
$pdo->exec("UPDATE authors SET email = '[email protected]' WHERE id = 1");
$row = $pdo->query(
"SELECT a.title, au.name, au.email
FROM articles a JOIN authors au ON au.id = a.author_id"
)->fetch(PDO::FETCH_ASSOC);
echo json_encode($row, JSON_UNESCAPED_UNICODE) . "\n";Output yang Dihasilkan:
Tag 'sql': []
Tag 'php': ["Belajar PHP"]
{"title":"Belajar PHP","name":"Budi","email":"[email protected]"}Bandingkan output tersebut dengan versi tabel berantakan di awal chapter. Pencarian tag 'sql' kini mengembalikan array kosong — benar, karena memang tidak ada tag tersebut — sementara tag 'php' tetap menemukan artikelnya secara persis. Perubahan email Budi dilakukan pada satu baris di satu tabel, dan query terakhir menunjukkan seluruh artikelnya otomatis melihat email baru. Fungsi articlesWithTag() memakai prepared statement dengan parameter ? sehingga bebas dari risiko SQL injection sesuai materi chapter SQL Injection Prevention
.
Kapan Denormalisasi Masuk Akal #
Normalization bukan dogma yang harus ditegakkan tanpa pandang bulu. Schema yang sangat normal kadang memaksa query laporan untuk melakukan banyak JOIN, dan pada tabel berisi jutaan baris, biaya JOIN tersebut bisa terasa nyata. Denormalisasi — sengaja menyimpan kembali data yang digandakan — menjadi masuk akal pada beberapa situasi: kolom penghitung seperti comment_count yang diperbarui pemicu agar daftar artikel tidak perlu COUNT(*) terus-menerus, tabel ringkasan untuk dasbor yang dibangun ulang secara berkala, atau kolom snapshot seperti order_total_at_purchase yang memang harus membeku pada saat kejadian.
Ringkasan #
Kita telah mengubah satu tabel berantakan menjadi empat tabel yang setiap faktnya tersimpan tepat satu kali. 1NF menghilangkan nilai majemuk dengan memindahkannya ke tabel bantu, 2NF memindahkan kolom yang hanya bergantung pada sebagian composite key, dan 3NF memindahkan kolom yang bergantung transitif pada kolom non-key lain. Sebagai bonus, anomali insert, update, dan delete lenyap bersama redundansinya. Schema kini bentuknya benar; yang belum kita atur adalah nilai yang boleh masuk ke setiap kolom — pekerjaan constraints pada chapter berikutnya.