Peta bentuk normal di chapter Normalisasi Database menjanjikan tiga jenjang, dan jenjang pertama dimulai dari aturan yang tampak sepele: satu sel, satu nilai. Tabel pesanan_flat di chapter itu kebetulan sudah lolos aturan ini, tetapi keberuntungan tidak berlanjut ketika Toko Maju Jaya mulai memindahkan daftar kontak lama dari buku telepon ke database. Formulir pendaftaran lamanya meminta “semua nomor telepon, pisahkan dengan koma” dan “minat belanja, pisahkan dengan koma” — dan hasil impornya menyimpan banyak nilai dalam satu sel. Chapter ini membuktikan kenapa bentuk seperti itu melukai setiap query, lalu memperbaikinya dengan tiga obat yang berbeda: baris terpisah, array PostgreSQL, dan junction table.

Aturan Satu Sel, Satu Nilai #

First Normal Form (1NF) mengatur dua hal sekaligus. Pertama, atomicity: setiap sel memuat tepat satu nilai yang tidak perlu dibedah lagi oleh database untuk diperiksa. Kedua, larangan repeating group: tidak ada kolom tiruan bernama telepon1, telepon2, telepon3 yang sebenarnya adalah satu jenis fakta yang disalin berulang. Nilai dianggap atomik bukan dari panjangnya, melainkan dari pertanyaannya — apakah database pernah harus memotong isi sel itu untuk menjawab “nomor mana yang cocok?” Tiga wujud pelanggaran yang paling sering ditemui:

Wujud pelanggaranContoh nyataGejala khas
daftar dipisah koma dalam satu kolom'0812-1111-2222, 0813-3333-4444'LIKE salah sasaran, agregasi butuh bedah string
kolom berulang (repeating group)telepon1, telepon2, telepon3pencarian memakai OR berantai, jumlah kolom terpasung
array yang bersembunyisatu kolom menyimpan daftar berlabelelemen tidak bisa di-join, dihitung, atau dijaga unik

Pelanggaran: Daftar Dipisah Koma dalam Satu Kolom #

Tabel berikut adalah hasil impor formulir lama apa adanya. Dua kolomnya — telepon dan minat — masing-masing menyimpan daftar, bukan nilai:

-- Tabel kontak lama Toko Maju Jaya: hasil impor formulir pendaftaran
-- yang meminta "semua nomor telepon, pisahkan dengan koma" dan
-- "minat belanja, pisahkan dengan koma".
CREATE TABLE pelanggan_lama (
    id      integer PRIMARY KEY,
    nama    text NOT NULL,
    telepon text NOT NULL,
    minat   text NOT NULL
);

INSERT INTO pelanggan_lama (id, nama, telepon, minat) VALUES
    (1, 'Budi Santoso', '0812-1111-2222, 0813-3333-4444', 'alat tulis, kertas'),
    (2, 'Siti Rahayu',  '0813-3333-4555',                  'kertas'),
    (3, 'Andi Wijaya',  '0815-9999-8888',                  'alat tulis, buku');

SELECT id, nama, telepon, minat
FROM pelanggan_lama
ORDER BY id;

-- Petugas mencari pemilik nomor 0813-3333-4444 dengan LIKE:
-- pola '%0813-3333-4%' menangkap SEMUA nomor yang memuat potongan itu,
-- termasuk nomor Siti yang hanya berbagi awalan.
SELECT id, nama
FROM pelanggan_lama
WHERE telepon LIKE '%0813-3333-4%'
ORDER BY id;

-- Bedah string pun berjebakan: string_to_array memotong pada koma,
-- tapi elemen kedua masih menyimpan spasi di depannya.
SELECT id, string_to_array(telepon, ',') AS daftar_telepon
FROM pelanggan_lama
ORDER BY id;

-- Akibat spasi itu, pencocokan persis lewat = ANY gagal diam-diam:
-- tidak ada satu pun baris yang dianggap cocok.
SELECT id, nama
FROM pelanggan_lama
WHERE '0813-3333-4444' = ANY (string_to_array(telepon, ','))
ORDER BY id;

-- Setelah setiap elemen dirapikan dengan trim, pencocokan baru bekerja --
-- tiga lapis pembedah string hanya untuk satu pencarian nomor.
SELECT id, nama
FROM pelanggan_lama
WHERE '0813-3333-4444' = ANY (
    SELECT trim(elemen)
    FROM unnest(string_to_array(telepon, ',')) AS elemen
)
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 3
 id |     nama     |            telepon             |       minat        
----+--------------+--------------------------------+--------------------
  1 | Budi Santoso | 0812-1111-2222, 0813-3333-4444 | alat tulis, kertas
  2 | Siti Rahayu  | 0813-3333-4555                 | kertas
  3 | Andi Wijaya  | 0815-9999-8888                 | alat tulis, buku
(3 rows)

 id |     nama     
----+--------------
  1 | Budi Santoso
  2 | Siti Rahayu
(2 rows)

 id |           daftar_telepon           
----+------------------------------------
  1 | {0812-1111-2222," 0813-3333-4444"}
  2 | {0813-3333-4555}
  3 | {0815-9999-8888}
(3 rows)

 id | nama 
----+------
(0 rows)

 id |     nama     
----+--------------
  1 | Budi Santoso
(1 row)

Empat query berikutnya adalah bukti rusaknya tabel itu, dan masing-masing rusak dengan cara berbeda:

  1. WHERE telepon LIKE '%0813-3333-4%' — query mencari pemilik 0813-3333-4444 (nomor Budi), tetapi hasilnya mengembalikan dua baris: Budi dan Siti. Nomor Siti 0813-3333-4555 ikut tertangkap karena memuat potongan 0813-3333-4 sebagai bagian dari deretan karakternya. Pencarian substring tidak mengerti batas antarnomor.
  2. string_to_array(telepon, ',') — memotong sel menjadi array, tetapi hasilnya {0812-1111-2222," 0813-3333-4444"}: elemen kedua masih membawa satu spasi di depannya karena pemisah aslinya adalah koma plus spasi.
  3. '0813-3333-4444' = ANY (string_to_array(...)) — pencocokan persis gagal total: nol baris. Spasi tunggal itu cukup untuk membuat ' 0813-3333-4444' berbeda dari '0813-3333-4444', dan kegagalannya sunyi tanpa error.
  4. trim di dalam unnest — baris Budi akhirnya ditemukan, dengan harga tiga lapis pembedah string yang harus ditulis ulang di setiap query yang menyentuh kolom telepon.

Pelanggaran: Kolom Telepon yang Berulang #

Bentuk kedua tampak seperti perbaikan: pemilik toko membuat tiga kolom terpisah. Setiap sel kini benar-benar satu nomor, tetapi repeating group melanggar 1NF lewat pintu belakang — jenis fakta yang sama dipaksa menyebar ke banyak kolom:

-- Variasi kedua pelanggaran 1NF: kolom berulang (repeating groups).
-- Pemilik toko "menyelesaikan" masalah banyak nomor dengan tiga kolom.
CREATE TABLE pelanggan_kolom (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    telepon1 text,
    telepon2 text,
    telepon3 text
);

INSERT INTO pelanggan_kolom (id, nama, telepon1, telepon2, telepon3) VALUES
    (1, 'Budi Santoso', '0812-1111-2222', '0813-3333-4444', NULL),
    (2, 'Siti Rahayu',  '0813-3333-4555', NULL, NULL);

SELECT id, nama, telepon1, telepon2, telepon3
FROM pelanggan_kolom
ORDER BY id;

-- Pencarian satu nomor kini harus menyebut ketiga kolom satu per satu;
-- lupa satu kolom berarti lupa satu tempat penyimpanan.
SELECT id, nama
FROM pelanggan_kolom
WHERE telepon1 = '0813-3333-4444'
   OR telepon2 = '0813-3333-4444'
   OR telepon3 = '0813-3333-4444'
ORDER BY id;

-- "Berapa nomor yang tersimpan untuk tiap pelanggan?" terjemahannya:
-- jumlahkan tiga kolom yang tidak kosong. Tambah telepon4, dan seluruh
-- query seperti ini harus ditulis ulang.
SELECT id, nama,
       (telepon1 IS NOT NULL)::int
     + (telepon2 IS NOT NULL)::int
     + (telepon3 IS NOT NULL)::int AS jumlah_nomor
FROM pelanggan_kolom
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id |     nama     |    telepon1    |    telepon2    | telepon3 
----+--------------+----------------+----------------+----------
  1 | Budi Santoso | 0812-1111-2222 | 0813-3333-4444 | 
  2 | Siti Rahayu  | 0813-3333-4555 |                | 
(2 rows)

 id |     nama     
----+--------------
  1 | Budi Santoso
(1 row)

 id |     nama     | jumlah_nomor 
----+--------------+--------------
  1 | Budi Santoso |            2
  2 | Siti Rahayu  |            1
(2 rows)

Dua query terakhir memperlihatkan pola penyakitnya. Pencarian satu nomor harus menyebut tiga kolom dengan rantai OR — satu kolom yang terlupakan berarti satu tempat penyimpanan yang tidak diperiksa. Menghitung jumlah nomor berubah menjadi penjumlahan tiga IS NOT NULL yang dikonversi ke integer. Batas tiga nomor juga dipaku permanen: pelanggan dengan nomor keempat menuntut ALTER TABLE menambah kolom baru, lalu setiap query OR berantai dan setiap laporan yang menyebut telepon1 sampai telepon3 harus dikejar satu per satu.

Perbaikan Struktural: Nilai Banyak Menjadi Baris #

Obat pertama untuk data bernilai-banyak adalah relasi satu-ke-banyak yang sudah kita kuasai: tabel telepon berdiri sendiri, setiap nomor menjadi satu baris, terikat pelanggan_id sebagai foreign key:

-- Perbaikan pertama: data bernilai-banyak pindah menjadi baris di tabelnya
-- sendiri, terikat foreign key ke pelanggan.
CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

INSERT INTO pelanggan (id, nama) VALUES
    (1, 'Budi Santoso'),
    (2, 'Siti Rahayu'),
    (3, 'Andi Wijaya'),
    (4, 'Dewi Lestari');

CREATE TABLE telepon (
    pelanggan_id integer NOT NULL REFERENCES pelanggan (id),
    jenis        text    NOT NULL,
    nomor        text    NOT NULL,
    PRIMARY KEY (pelanggan_id, nomor)
);

INSERT INTO telepon (pelanggan_id, jenis, nomor) VALUES
    (1, 'rumah',   '0812-1111-2222'),
    (1, 'pribadi', '0813-3333-4444'),
    (2, 'pribadi', '0813-3333-4555'),
    (3, 'kantor',  '0815-9999-8888');

-- Pencarian nomor menjadi pencocokan persis pada satu kolom;
-- tidak ada lagi LIKE, tidak ada lagi spasi tersembunyi.
SELECT p.nama, t.jenis, t.nomor
FROM pelanggan p
JOIN telepon t ON t.pelanggan_id = p.id
WHERE t.nomor = '0813-3333-4444'
ORDER BY p.id;

-- "Berapa nomor per pelanggan?" menjadi COUNT biasa;
-- LEFT JOIN memastikan pelanggan tanpa nomor tetap terhitung nol.
SELECT p.id, p.nama, count(t.nomor) AS jumlah_nomor
FROM pelanggan p
LEFT JOIN telepon t ON t.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;

-- Pelanggan keempat mendaftar dengan nomor keempat jenisnya sendiri:
-- tanpa ALTER TABLE, tanpa query yang ditulis ulang.
INSERT INTO telepon (pelanggan_id, jenis, nomor)
VALUES (4, 'darurat', '0817-2222-3333');

SELECT p.nama, t.jenis, t.nomor
FROM pelanggan p
JOIN telepon t ON t.pelanggan_id = p.id
ORDER BY p.id, t.nomor;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 4
CREATE TABLE
INSERT 0 4
     nama     |  jenis  |     nomor      
--------------+---------+----------------
 Budi Santoso | pribadi | 0813-3333-4444
(1 row)

 id |     nama     | jumlah_nomor 
----+--------------+--------------
  1 | Budi Santoso |            2
  2 | Siti Rahayu  |            1
  3 | Andi Wijaya  |            1
  4 | Dewi Lestari |            0
(4 rows)

INSERT 0 1
     nama     |  jenis  |     nomor      
--------------+---------+----------------
 Budi Santoso | rumah   | 0812-1111-2222
 Budi Santoso | pribadi | 0813-3333-4444
 Siti Rahayu  | pribadi | 0813-3333-4555
 Andi Wijaya  | kantor  | 0815-9999-8888
 Dewi Lestari | darurat | 0817-2222-3333
(5 rows)

Tiga query penutup menuntaskan tiga penyakit lama sekaligus. Pencarian nomor menjadi pencocokan persis t.nomor = '0813-3333-4444' — satu kolom, tanpa LIKE, tanpa spasi tersembunyi. Jumlah nomor per pelanggan menjadi count(t.nomor) dengan LEFT JOIN, dan pelanggan tanpa nomor pun tetap muncul dengan angka nol. Insert terakhir menambah nomor jenis darurat untuk Dewi tanpa menyentuh struktur tabel sama sekali: baris baru, bukan kolom baru. Bonus yang tidak kalah penting: kolom jenis kini ada tempatnya — hal mustahil pada daftar koma, yang tidak punya cara menyimpan label per elemen.

Nuansa Array PostgreSQL #

PostgreSQL menyediakan jalan tengah yang membuat diskusi 1NF menjadi lebih menarik: tipe text[] yang menyimpan daftar dalam satu kolom. Secara tampilan ini mirip pelanggaran — satu sel berisi banyak nilai — tetapi array berbeda secara fundamental dari string berkoma: array punya struktur internal yang dikenali server, operator khusus (unnest, @>, ANY), dan bisa diindeks. Untuk data seperti topik minat yang diperlakukan sebagai satu kesatuan, array adalah pilihan yang sah:

-- Perbaikan kedua: minat belanja disimpan sebagai array PostgreSQL.
-- Tiap sel tetap berisi satu NILAI -- sebuah himpunan topik yang diperlakukan
-- sebagai satu kesatuan, dengan operator khusus untuk memeriksanya.
ALTER TABLE pelanggan ADD COLUMN minat text[];

UPDATE pelanggan SET minat = ARRAY['alat tulis', 'kertas'] WHERE id = 1;
UPDATE pelanggan SET minat = ARRAY['kertas']                WHERE id = 2;
UPDATE pelanggan SET minat = ARRAY['alat tulis', 'buku']    WHERE id = 3;
UPDATE pelanggan SET minat = ARRAY['kertas', 'kertas']      WHERE id = 4;

SELECT id, nama, minat
FROM pelanggan
ORDER BY id;

-- unnest membongkar array menjadi baris saat detailnya dibutuhkan.
SELECT id, nama, unnest(minat) AS topik
FROM pelanggan
ORDER BY id, topik;

-- Operator @> menjawab "pelanggan ini punya minat kertas?" tanpa bedah string.
SELECT id, nama
FROM pelanggan
WHERE minat @> ARRAY['kertas']
ORDER BY id;

-- Agregasi tetap sederhana: berapa pelanggan menyukai 'alat tulis'?
SELECT count(*) AS penyuka_alat_tulis
FROM pelanggan
WHERE 'alat tulis' = ANY (minat);

Output yang Dihasilkan:

text
ALTER TABLE
UPDATE 1
UPDATE 1
UPDATE 1
UPDATE 1
 id |     nama     |         minat         
----+--------------+-----------------------
  1 | Budi Santoso | {"alat tulis",kertas}
  2 | Siti Rahayu  | {kertas}
  3 | Andi Wijaya  | {"alat tulis",buku}
  4 | Dewi Lestari | {kertas,kertas}
(4 rows)

 id |     nama     |   topik    
----+--------------+------------
  1 | Budi Santoso | alat tulis
  1 | Budi Santoso | kertas
  2 | Siti Rahayu  | kertas
  3 | Andi Wijaya  | alat tulis
  3 | Andi Wijaya  | buku
  4 | Dewi Lestari | kertas
  4 | Dewi Lestari | kertas
(7 rows)

 id |     nama     
----+--------------
  1 | Budi Santoso
  2 | Siti Rahayu
  4 | Dewi Lestari
(3 rows)

 penyuka_alat_tulis 
--------------------
                  2
(1 row)

Perhatikan urutan operasinya. unnest(minat) membongkar array menjadi baris tanpa pembedah string dan tanpa jebakan spasi — server tahu persis di mana batas antarelemen. Operator minat @> ARRAY['kertas'] menjawab pertanyaan keanggotaan langsung dari server. Namun hasil query terakhir menyimpan satu fakta yang tidak nyaman: UPDATE untuk pelanggan 4 sengaja memasukkan ARRAY['kertas', 'kertas'], dan server menerimanya tanpa protes — array tidak menjaga keunikan elemen, tidak mendukung foreign key per elemen, dan elemennya tidak bisa di-join sebagai tabel.

Kapan Array Cukup, Kapan Junction Table #

Ujian praktisnya satu kalimat: ketika hubungan mulai penting, pindahkan elemen ke junction table. Topik minat yang hanya dipakai untuk filter cukup tinggal di array; begitu topik mau dihitung, di-join ke tabel topik induk, dijaga unik, atau diberi atribut tambahan — pindahkan ke baris. File berikut memigrasikan kolom minat menjadi tabel minat_pelanggan dengan composite primary key:

sql batas-array.sql
-- Batas array: ketika elemen daftar mulai punya identitas relasional,
-- tempat yang benar adalah junction table, bukan satu kolom.
CREATE TABLE minat_pelanggan (
    pelanggan_id integer NOT NULL REFERENCES pelanggan (id),
    topik        text    NOT NULL,
    PRIMARY KEY (pelanggan_id, topik)
);

-- Migrasi array menjadi baris: unnest membongkar isi kolom,
-- DISTINCT menyapu bersih duplikat 'kertas' milik Dewi.
INSERT INTO minat_pelanggan (pelanggan_id, topik)
SELECT DISTINCT id, unnest(minat) AS topik
FROM pelanggan
ORDER BY id, topik;

SELECT pelanggan_id, topik
FROM minat_pelanggan
ORDER BY pelanggan_id, topik;

-- Pertanyaan agregasi per topik kini query biasa pada tabel biasa.
SELECT topik, count(*) AS peminat
FROM minat_pelanggan
GROUP BY topik
ORDER BY topik;

-- Join lintas tabel menjadi mungkin: siapa saja penyuka kertas?
SELECT p.nama, mp.topik
FROM pelanggan p
JOIN minat_pelanggan mp ON mp.pelanggan_id = p.id
WHERE mp.topik = 'kertas'
ORDER BY p.id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 6
 pelanggan_id |   topik    
--------------+------------
            1 | alat tulis
            1 | kertas
            2 | kertas
            3 | alat tulis
            3 | buku
            4 | kertas
(6 rows)

   topik    | peminat 
------------+---------
 alat tulis |       2
 buku       |       1
 kertas     |       3
(3 rows)

     nama     | topik  
--------------+--------
 Budi Santoso | kertas
 Siti Rahayu  | kertas
 Dewi Lestari | kertas
(3 rows)

Migrasinya membuktikan dua hal sekaligus. unnest membongkar array menjadi kandidat baris, dan DISTINCT menyapu bersih duplikat kertas milik Dewi sehingga tabel hasil migrasi bersih sejak lahir — enam nilai array menjadi empat baris unik. Setelah menjadi tabel, semua operasi relational tersedia: agregasi count(*) per topik, join ke pelanggan, dan penjagaan keunikan. Penjagaan keunikan yang terakhir itulah yang dicoba dipecahkan file berikut:

-- Junction table menolak pasangan ganda lewat composite primary key --
-- penolakan yang tidak pernah diberikan oleh array.
INSERT INTO minat_pelanggan (pelanggan_id, topik)
VALUES (1, 'kertas');

Output yang Dihasilkan:

text
psql:tolak-duplikat-topik.sql:4: ERROR:  duplicate key value violates unique constraint "minat_pelanggan_pkey"
DETAIL:  Key (pelanggan_id, topik)=(1, kertas) already exists.

Server menolak insert dengan error duplicate key value violates unique constraint "minat_pelanggan_pkey" karena pasangan (1, 'kertas') sudah tercatat — penolakan yang sama sekali tidak ada pada versi array, yang menerima 'kertas' berkali-kali tanpa suara. Perbandingkan ketiga rumah untuk data bernilai-banyak:

AspekString berkomaArray text[]Junction table
pencarian satu elemenLIKE rawan salah sasaranoperator @>, ANYquery biasa + index
keunikan elementidak dijagatidak dijagacomposite primary key
atribut per elemenmustahilmustahilkolom tambahan di tabel
foreign key per elemenmustahilmustahilmendukung penuh
cocok untuktidak pernahtag dan label sederhanarelasi yang penting

Daftar Uji 1NF #

Normalisasi bekerja seperti inspeksi: jalankan daftar periksa berikut pada setiap tabel yang Anda rancang, dan tabel yang lolos semua pertanyaan sudah berdiri di 1NF:

Pertanyaan ujiLolos bila
Apakah setiap sel bisa diperiksa tanpa memotong isinya?tidak ada LIKE, split_part, atau string_to_array untuk mencari satu nilai
Apakah jenis fakta yang sama hanya punya satu kolom?tidak ada telepon1..teleponN atau kolom berujung angka
Apakah satu nilai punya satu tempat pencarian?query tidak butuh rantai OR lintas kolom sejenis
Apakah elemen daftar punya kebutuhan relasional?bila ya, elemen sudah menjadi baris tabel, bukan isi array

Tabel pelanggan_lama gagal di tiga pertanyaan pertama; hasil akhirnya — pelanggan, telepon, dan minat_pelanggan — lolos keempatnya. Namun 1NF baru memeriksa isi sel; 1NF belum bertanya bagaimana kolom-kolom bergantung pada kunci tabel. Ketika kuncinya composite — dua kolom yang menjadi satu primary key — kolom non-kunci bisa bersandar hanya pada separuh kunci, dan ketergantungan parsial itulah lawan berikutnya di chapter Second Normal Form .

Referensi #

Berkas Sumber

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

batas-array.sql

Unduh berkas
sql
-- Batas array: ketika elemen daftar mulai punya identitas relasional,
-- tempat yang benar adalah junction table, bukan satu kolom.
CREATE TABLE minat_pelanggan (
    pelanggan_id integer NOT NULL REFERENCES pelanggan (id),
    topik        text    NOT NULL,
    PRIMARY KEY (pelanggan_id, topik)
);

-- Migrasi array menjadi baris: unnest membongkar isi kolom,
-- DISTINCT menyapu bersih duplikat 'kertas' milik Dewi.
INSERT INTO minat_pelanggan (pelanggan_id, topik)
SELECT DISTINCT id, unnest(minat) AS topik
FROM pelanggan
ORDER BY id, topik;

SELECT pelanggan_id, topik
FROM minat_pelanggan
ORDER BY pelanggan_id, topik;

-- Pertanyaan agregasi per topik kini query biasa pada tabel biasa.
SELECT topik, count(*) AS peminat
FROM minat_pelanggan
GROUP BY topik
ORDER BY topik;

-- Join lintas tabel menjadi mungkin: siapa saja penyuka kertas?
SELECT p.nama, mp.topik
FROM pelanggan p
JOIN minat_pelanggan mp ON mp.pelanggan_id = p.id
WHERE mp.topik = 'kertas'
ORDER BY p.id;

kolom-berulang.sql

Unduh berkas
sql
-- Variasi kedua pelanggaran 1NF: kolom berulang (repeating groups).
-- Pemilik toko "menyelesaikan" masalah banyak nomor dengan tiga kolom.
CREATE TABLE pelanggan_kolom (
    id       integer PRIMARY KEY,
    nama     text NOT NULL,
    telepon1 text,
    telepon2 text,
    telepon3 text
);

INSERT INTO pelanggan_kolom (id, nama, telepon1, telepon2, telepon3) VALUES
    (1, 'Budi Santoso', '0812-1111-2222', '0813-3333-4444', NULL),
    (2, 'Siti Rahayu',  '0813-3333-4555', NULL, NULL);

SELECT id, nama, telepon1, telepon2, telepon3
FROM pelanggan_kolom
ORDER BY id;

-- Pencarian satu nomor kini harus menyebut ketiga kolom satu per satu;
-- lupa satu kolom berarti lupa satu tempat penyimpanan.
SELECT id, nama
FROM pelanggan_kolom
WHERE telepon1 = '0813-3333-4444'
   OR telepon2 = '0813-3333-4444'
   OR telepon3 = '0813-3333-4444'
ORDER BY id;

-- "Berapa nomor yang tersimpan untuk tiap pelanggan?" terjemahannya:
-- jumlahkan tiga kolom yang tidak kosong. Tambah telepon4, dan seluruh
-- query seperti ini harus ditulis ulang.
SELECT id, nama,
       (telepon1 IS NOT NULL)::int
     + (telepon2 IS NOT NULL)::int
     + (telepon3 IS NOT NULL)::int AS jumlah_nomor
FROM pelanggan_kolom
ORDER BY id;

pelanggaran-1nf.sql

Unduh berkas
sql
-- Tabel kontak lama Toko Maju Jaya: hasil impor formulir pendaftaran
-- yang meminta "semua nomor telepon, pisahkan dengan koma" dan
-- "minat belanja, pisahkan dengan koma".
CREATE TABLE pelanggan_lama (
    id      integer PRIMARY KEY,
    nama    text NOT NULL,
    telepon text NOT NULL,
    minat   text NOT NULL
);

INSERT INTO pelanggan_lama (id, nama, telepon, minat) VALUES
    (1, 'Budi Santoso', '0812-1111-2222, 0813-3333-4444', 'alat tulis, kertas'),
    (2, 'Siti Rahayu',  '0813-3333-4555',                  'kertas'),
    (3, 'Andi Wijaya',  '0815-9999-8888',                  'alat tulis, buku');

SELECT id, nama, telepon, minat
FROM pelanggan_lama
ORDER BY id;

-- Petugas mencari pemilik nomor 0813-3333-4444 dengan LIKE:
-- pola '%0813-3333-4%' menangkap SEMUA nomor yang memuat potongan itu,
-- termasuk nomor Siti yang hanya berbagi awalan.
SELECT id, nama
FROM pelanggan_lama
WHERE telepon LIKE '%0813-3333-4%'
ORDER BY id;

-- Bedah string pun berjebakan: string_to_array memotong pada koma,
-- tapi elemen kedua masih menyimpan spasi di depannya.
SELECT id, string_to_array(telepon, ',') AS daftar_telepon
FROM pelanggan_lama
ORDER BY id;

-- Akibat spasi itu, pencocokan persis lewat = ANY gagal diam-diam:
-- tidak ada satu pun baris yang dianggap cocok.
SELECT id, nama
FROM pelanggan_lama
WHERE '0813-3333-4444' = ANY (string_to_array(telepon, ','))
ORDER BY id;

-- Setelah setiap elemen dirapikan dengan trim, pencocokan baru bekerja --
-- tiga lapis pembedah string hanya untuk satu pencarian nomor.
SELECT id, nama
FROM pelanggan_lama
WHERE '0813-3333-4444' = ANY (
    SELECT trim(elemen)
    FROM unnest(string_to_array(telepon, ',')) AS elemen
)
ORDER BY id;

perbaikan-array.sql

Unduh berkas
sql
-- Perbaikan kedua: minat belanja disimpan sebagai array PostgreSQL.
-- Tiap sel tetap berisi satu NILAI -- sebuah himpunan topik yang diperlakukan
-- sebagai satu kesatuan, dengan operator khusus untuk memeriksanya.
ALTER TABLE pelanggan ADD COLUMN minat text[];

UPDATE pelanggan SET minat = ARRAY['alat tulis', 'kertas'] WHERE id = 1;
UPDATE pelanggan SET minat = ARRAY['kertas']                WHERE id = 2;
UPDATE pelanggan SET minat = ARRAY['alat tulis', 'buku']    WHERE id = 3;
UPDATE pelanggan SET minat = ARRAY['kertas', 'kertas']      WHERE id = 4;

SELECT id, nama, minat
FROM pelanggan
ORDER BY id;

-- unnest membongkar array menjadi baris saat detailnya dibutuhkan.
SELECT id, nama, unnest(minat) AS topik
FROM pelanggan
ORDER BY id, topik;

-- Operator @> menjawab "pelanggan ini punya minat kertas?" tanpa bedah string.
SELECT id, nama
FROM pelanggan
WHERE minat @> ARRAY['kertas']
ORDER BY id;

-- Agregasi tetap sederhana: berapa pelanggan menyukai 'alat tulis'?
SELECT count(*) AS penyuka_alat_tulis
FROM pelanggan
WHERE 'alat tulis' = ANY (minat);

perbaikan-baris.sql

Unduh berkas
sql
-- Perbaikan pertama: data bernilai-banyak pindah menjadi baris di tabelnya
-- sendiri, terikat foreign key ke pelanggan.
CREATE TABLE pelanggan (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

INSERT INTO pelanggan (id, nama) VALUES
    (1, 'Budi Santoso'),
    (2, 'Siti Rahayu'),
    (3, 'Andi Wijaya'),
    (4, 'Dewi Lestari');

CREATE TABLE telepon (
    pelanggan_id integer NOT NULL REFERENCES pelanggan (id),
    jenis        text    NOT NULL,
    nomor        text    NOT NULL,
    PRIMARY KEY (pelanggan_id, nomor)
);

INSERT INTO telepon (pelanggan_id, jenis, nomor) VALUES
    (1, 'rumah',   '0812-1111-2222'),
    (1, 'pribadi', '0813-3333-4444'),
    (2, 'pribadi', '0813-3333-4555'),
    (3, 'kantor',  '0815-9999-8888');

-- Pencarian nomor menjadi pencocokan persis pada satu kolom;
-- tidak ada lagi LIKE, tidak ada lagi spasi tersembunyi.
SELECT p.nama, t.jenis, t.nomor
FROM pelanggan p
JOIN telepon t ON t.pelanggan_id = p.id
WHERE t.nomor = '0813-3333-4444'
ORDER BY p.id;

-- "Berapa nomor per pelanggan?" menjadi COUNT biasa;
-- LEFT JOIN memastikan pelanggan tanpa nomor tetap terhitung nol.
SELECT p.id, p.nama, count(t.nomor) AS jumlah_nomor
FROM pelanggan p
LEFT JOIN telepon t ON t.pelanggan_id = p.id
GROUP BY p.id, p.nama
ORDER BY p.id;

-- Pelanggan keempat mendaftar dengan nomor keempat jenisnya sendiri:
-- tanpa ALTER TABLE, tanpa query yang ditulis ulang.
INSERT INTO telepon (pelanggan_id, jenis, nomor)
VALUES (4, 'darurat', '0817-2222-3333');

SELECT p.nama, t.jenis, t.nomor
FROM pelanggan p
JOIN telepon t ON t.pelanggan_id = p.id
ORDER BY p.id, t.nomor;

tolak-duplikat-topik.sql

Unduh berkas
sql
-- Junction table menolak pasangan ganda lewat composite primary key --
-- penolakan yang tidak pernah diberikan oleh array.
INSERT INTO minat_pelanggan (pelanggan_id, topik)
VALUES (1, 'kertas');