Tiga bentuk normal menuntaskan pekerjaan besar: setiap fakta di Toko Maju Jaya kini hidup tepat satu kali, dan anomali insert, update, delete sudah tidak punya tempat bersembunyi. Namun normalisasi membayar semua itu dengan satu mata uang — join. Laporan penjualan yang dulu dibaca dari satu tabel kini menyusun pesanan, item_pesanan, pelanggan, dan produk sebelum angka pertama tercetak, dan pada database dengan jutaan transaksi, pembayaran itu terasa. Denormalisasi adalah keputusan yang berlawanan arah dengan tiga chapter sebelumnya: sengaja menyimpan salinan, hasil hitungan, atau ringkasan demi kecepatan baca — dengan mata terbuka terhadap harga yang menyertainya. Chapter ini membedah kapan keputusan itu masuk akal, pola-pola praktisnya, dan cara menjaga redundansi yang sudah disengaja tetap jujur.
Normalisasi Membayar dengan Join #
Alasan denormalisasi selalu berangkat dari profil beban: berapa banyak pembacaan dibandingkan penulisan. Sistem pencatatan transaksi menulis terus-menerus dan membaca sesekali — di sana normalisasi menang mutlak. Sebaliknya, dashboard laporan bulanan dibaca setiap detik oleh banyak pengguna namun datanya berubah sekali sehari — di sana join berat yang diulang-ulang adalah pemborosan. Kerangka berpikirnya:
| Kondisi sistem | Pilihan yang sehat |
|---|---|
| tulis sering, baca jarang | normalisasi penuh; join dihitung saat dibutuhkan |
| baca berat dan berulang pada agregasi yang sama | kandidat denormalisasi |
| nilainya selalu diturunkan dari kolom lain | generated column — aman karena dijaga server |
| laporan resmi dibaca jauh lebih sering daripada dihitung | tabel ringkasan dengan jadwal penyegaran |
Basis data kita hari ini murni dinormalisasi — file pembuka berikut adalah titik berangkat, lengkap dengan query yang membayar ongkos join setiap kali total pesanan dibutuhkan:
-- Basis data dinormalisasi Toko Maju Jaya: hasil akhir rantai 1NF-2NF-3NF.
-- Setiap fakta hidup satu kali; total pesanan TIDAK disimpan --
-- dihitung dari item saat dibutuhkan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL
);
CREATE TABLE produk (
id integer PRIMARY KEY,
nama text NOT NULL,
harga numeric(12, 2) NOT NULL
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
pelanggan_id integer NOT NULL REFERENCES pelanggan (id)
);
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, 'Penggaris', 2500.00);
INSERT INTO pesanan (id, tanggal, pelanggan_id) VALUES
(901, DATE '2026-01-05', 1),
(902, DATE '2026-01-12', 2),
(903, DATE '2026-02-03', 1),
(904, DATE '2026-02-10', 2);
-- harga_saat_transaksi adalah snapshot harga saat transaksi --
-- pola dari chapter Junction Table yang tetap dipertahankan.
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
(901, 1, 10, 4500.00),
(901, 2, 5, 3000.00),
(902, 1, 2, 4500.00),
(903, 3, 4, 2500.00),
(904, 2, 6, 3000.00),
(904, 3, 1, 2500.00);
-- Total per pesanan dihitung, bukan disimpan: satu-satunya sumber
-- kebenaran adalah item_pesanan.
SELECT ps.id, ps.tanggal, pl.nama AS pelanggan,
SUM(i.kuantitas * i.harga_saat_transaksi) AS total
FROM pesanan ps
JOIN pelanggan pl ON pl.id = ps.pelanggan_id
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id, ps.tanggal, pl.nama
ORDER BY ps.id;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
INSERT 0 4
INSERT 0 6
id | tanggal | pelanggan | total
-----+------------+--------------+----------
901 | 2026-01-05 | Budi Santoso | 60000.00
902 | 2026-01-12 | Siti Rahayu | 9000.00
903 | 2026-02-03 | Budi Santoso | 10000.00
904 | 2026-02-10 | Siti Rahayu | 20500.00
(4 rows)Query penutupnya adalah target yang akan “dipercepat”: tiga tabel di-join, agregasi SUM dijalankan, total empat pesanan tercetak. Pada volume data toko ini hitungannya instan; bayangkan query yang sama pada jutaan baris dan dibuka setiap kali halaman daftar pesanan dirender — itulah titik ketika denormalisasi masuk daftar opsi. Satu catatan desain penting: harga_saat_transaksi bukan redundansi sembrono, melainkan snapshot yang dibahas di chapter Junction Table
— nilainya memang milik peristiwa, bukan salinan yang harus mengikuti katalog.
Pola Kolom Duplikat dan Harganya #
Pola paling gamblang: simpan hasil hitungan sebagai kolom biasa. total_tersimpan di tabel pesanan membuat laporan cukup membaca satu kolom tanpa join — dan file berikut membuktikan bahwa pola ini bekerja, lalu langsung membuktikan cara pola ini bocor:
-- Pola kolom duplikat: total disimpan di pesanan supaya laporan
-- tidak perlu join dan agregasi setiap kali dibuka.
ALTER TABLE pesanan ADD COLUMN total_tersimpan numeric(12, 2);
-- Pengisian awal: salin hasil agregasi ke kolom duplikat.
UPDATE pesanan ps
SET total_tersimpan = t.harga_total
FROM (SELECT id_pesanan, SUM(kuantitas * harga_saat_transaksi) AS harga_total
FROM item_pesanan
GROUP BY id_pesanan) AS t
WHERE t.id_pesanan = ps.id;
SELECT id, tanggal, total_tersimpan
FROM pesanan
ORDER BY id;
-- Kehidupan berjalan: pelanggan 901 menambah dua Pulpen
-- (kuantitas 5 menjadi 7) lewat UPDATE yang wajar.
UPDATE item_pesanan SET kuantitas = 7
WHERE id_pesanan = 901 AND id_produk = 2;
-- Kolom duplikat tidak ikut bergerak: total_tersimpan 901 masih
-- nilai lama, sementara perhitungan dari item sudah berubah.
SELECT ps.id, ps.total_tersimpan,
COALESCE(SUM(i.kuantitas * i.harga_saat_transaksi), 0) AS total_dihitung,
ps.total_tersimpan - COALESCE(SUM(i.kuantitas * i.harga_saat_transaksi), 0) AS selisih
FROM pesanan ps
LEFT JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id, ps.total_tersimpan
ORDER BY ps.id;Output yang Dihasilkan:
ALTER TABLE
UPDATE 4
id | tanggal | total_tersimpan
-----+------------+-----------------
901 | 2026-01-05 | 60000.00
902 | 2026-01-12 | 9000.00
903 | 2026-02-03 | 10000.00
904 | 2026-02-10 | 20500.00
(4 rows)
UPDATE 1
id | total_tersimpan | total_dihitung | selisih
-----+-----------------+----------------+----------
901 | 60000.00 | 66000.00 | -6000.00
902 | 9000.00 | 9000.00 | 0.00
903 | 10000.00 | 10000.00 | 0.00
904 | 20500.00 | 20500.00 | 0.00
(4 rows)Dua SELECT pertama menunjukkan polanya bekerja: total_tersimpan terisi benar untuk keempat pesanan, dan laporan kini membaca satu kolom polos. Lalu UPDATE item_pesanan yang paling wajar terjadi — pelanggan menambah kuantitas — dan query penutup memperlihatkan kebocorannya: pesanan 901 menyimpan 60000.00 sementara hitungan dari item sudah 66000.00, selisih bernilai -6000.00. Kolom selisih itu sendiri layak dicatat sebagai alat: query pembanding antara nilai tersimpan dan nilai dihitung adalah satu-satunya cara membuktikan kolom duplikat masih jujur. Anomali update dari chapter Normalisasi Database
tidak pernah pergi — anomali itu hanya menunggu diundang kembali, dan total_tersimpan adalah undangannya.
Versi Aman: Generated Column #
PostgreSQL menyediakan jalan tengah yang menarik: kolom yang disimpan sekaligus dijaga server. Generated column dihitung dari kolom lain di baris yang sama, disimpan ke disk (STORED) supaya terbaca tanpa menghitung ulang, dan sama sekali tidak bisa ditulis tangan:
-- Versi aman dari kolom terhitung: generated column.
-- Nilainya disimpan (STORED) untuk kecepatan baca, tetapi dihitung
-- dan dijaga oleh server -- bukan oleh disiplin petugas.
ALTER TABLE item_pesanan
ADD COLUMN subtotal numeric(12, 2)
GENERATED ALWAYS AS (kuantitas * harga_saat_transaksi) STORED;
-- subtotal selalu segar: baris 901-P02 otomatis mengikuti kuantitas 7
-- yang diubah pada file sebelumnya, tanpa ada UPDATE tambahan.
SELECT id_pesanan, id_produk, kuantitas, harga_saat_transaksi, subtotal
FROM item_pesanan
ORDER BY id_pesanan, id_produk;
-- Total per pesanan kini dihitung dari kolom yang sudah tersimpan --
-- agregasi tetap terjadi, tetapi tanpa perkalian per baris.
SELECT id_pesanan, SUM(subtotal) AS total
FROM item_pesanan
GROUP BY id_pesanan
ORDER BY id_pesanan;Output yang Dihasilkan:
ALTER TABLE
id_pesanan | id_produk | kuantitas | harga_saat_transaksi | subtotal
------------+-----------+-----------+----------------------+----------
901 | 1 | 10 | 4500.00 | 45000.00
901 | 2 | 7 | 3000.00 | 21000.00
902 | 1 | 2 | 4500.00 | 9000.00
903 | 3 | 4 | 2500.00 | 10000.00
904 | 2 | 6 | 3000.00 | 18000.00
904 | 3 | 1 | 2500.00 | 2500.00
(6 rows)
id_pesanan | total
------------+----------
901 | 66000.00
902 | 9000.00
903 | 10000.00
904 | 20500.00
(4 rows)Perhatikan baris 901 | 2 | 7 | 3000.00 | 21000.00: subtotal bernilai 21000.00 — mengikuti kuantitas 7 yang diubah file sebelumnya, tanpa satu pun perintah pemeliharaan tambahan. Server menghitung ulang nilai generated column setiap kali kolom sumbernya berubah, jadi kebocoran total_tersimpan tadi mustahil terjadi di sini. Batasannya juga perlu jujur dinyatakan: generated column hanya boleh membaca kolom pada baris yang sama, sehingga total per pesanan tetap butuh agregasi SUM — bedanya perkalian per baris sudah dikerjakan lebih awal. Usaha mengubahnya secara manual memang ditolak mentah-mentah:
-- Kolom duplikat manual bisa dibekukan oleh disiplin; generated column
-- dibekukan oleh server. UPDATE langsung ditolak.
UPDATE item_pesanan SET subtotal = 0.01
WHERE id_pesanan = 901 AND id_produk = 1;Output yang Dihasilkan:
psql:tolak-update-generated.sql:4: ERROR: column "subtotal" can only be updated to DEFAULT
DETAIL: Column "subtotal" is a generated column.Server menjawab column "subtotal" can only be updated to DEFAULT — kolom hasil hitungan milik server, bukan milik petugas. Ini kelas denormalisasi yang paling aman: redundansi tanpa kontrak pemeliharaan, karena pemeliharanya adalah mesinnya sendiri. Bahasan lengkapnya, termasuk variasi virtual, ada di chapter Generated Columns
.
Pola Tabel Ringkasan #
Ketika yang mahal bukan perkalian per baris melainkan agregasi lintas jutaan baris, target denormalisasi naik satu tingkat: tabel ringkasan. penjualan_bulanan menyimpan hasil agregasi seluruh riwayat ke dalam beberapa baris — diisi lewat INSERT ... SELECT, dibaca tanpa join sama sekali:
-- Pola tabel ringkasan: laporan bulanan disajikan dari satu tabel kecil,
-- bukan dari agregasi join tiga tabel berisi seluruh riwayat.
CREATE TABLE penjualan_bulanan (
bulan date PRIMARY KEY,
jumlah_pesanan integer NOT NULL,
total_pendapatan numeric(14, 2) NOT NULL
);
INSERT INTO penjualan_bulanan (bulan, jumlah_pesanan, total_pendapatan)
SELECT date_trunc('month', ps.tanggal)::date,
count(DISTINCT ps.id),
SUM(i.kuantitas * i.harga_saat_transaksi)
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
-- Laporan bulanan kini sekali baca, tanpa join.
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
ORDER BY bulan;
-- Kehidupan berjalan: pesanan baru masuk lewat jalur biasa.
INSERT INTO pesanan (id, tanggal, pelanggan_id)
VALUES (907, DATE '2026-02-20', 1);
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (907, 1, 4, 4500.00);
-- Tabel ringkasan tidak ikut bergerak: Februari tersimpan 35500.00,
-- sementara hitungan dari data hidup sudah 53500.00.
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
WHERE bulan = DATE '2026-02-01';
SELECT date_trunc('month', ps.tanggal)::date AS bulan,
count(DISTINCT ps.id) AS jumlah_pesanan,
SUM(i.kuantitas * i.harga_saat_transaksi) AS total_pendapatan
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
-- Siklus penyegaran: kosongkan, isi ulang dari sumber kebenaran.
TRUNCATE penjualan_bulanan;
INSERT INTO penjualan_bulanan (bulan, jumlah_pesanan, total_pendapatan)
SELECT date_trunc('month', ps.tanggal)::date,
count(DISTINCT ps.id),
SUM(i.kuantitas * i.harga_saat_transaksi)
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
ORDER BY bulan;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 2
bulan | jumlah_pesanan | total_pendapatan
------------+----------------+------------------
2026-01-01 | 2 | 75000.00
2026-02-01 | 2 | 30500.00
(2 rows)
INSERT 0 1
INSERT 0 1
bulan | jumlah_pesanan | total_pendapatan
------------+----------------+------------------
2026-02-01 | 2 | 30500.00
(1 row)
bulan | jumlah_pesanan | total_pendapatan
------------+----------------+------------------
2026-01-01 | 2 | 75000.00
2026-02-01 | 3 | 48500.00
(2 rows)
TRUNCATE TABLE
INSERT 0 2
bulan | jumlah_pesanan | total_pendapatan
------------+----------------+------------------
2026-01-01 | 2 | 75000.00
2026-02-01 | 3 | 48500.00
(2 rows)Siklus hidup tabel ringkasan terbaca penuh pada file itu. Pengisian awal memindahkan hasil agregasi Januari dan Februari ke dua baris. Pesanan 907 lalu masuk lewat jalur biasa — dan dua query berikutnya berdiri berdampingan sebagai pengakuan jujur: tabel ringkasan masih berkata 35500.00 untuk Februari, sementara sumber kebenaran berkata 53500.00. Staleness seperti ini bukan kecelakaan melainkan sifat: tabel ringkasan adalah foto, bukan cermin. TRUNCATE plus INSERT ... SELECT menyegarkan fotonya, dan Februari terbaca kembali benar. Keputusan desainnya adalah seberapa foto boleh kadaluarsa — laporan resmi yang disegarkan tiap dini hari sangat berbeda dari dashboard yang harus segan. Pola yang lebih terotomasi untuk masalah yang sama dibahas di section Views dan Materialized Views
.
Pola Penghitung yang Dijaga Trigger #
Pola terakhir menyimpan agregat per entitas induk: jumlah_terjual di tabel produk membuat profil produk membaca satu kolom alih-alih memindai seluruh item_pesanan. Kolumnya sengaja dijaga oleh trigger — potongan otomatis dari section Triggers dan Events
yang diperkenalkan di sini sebatas pratinjaunya:
-- Pola penghitung: jumlah_terjual disimpan di produk supaya profil
-- produk tidak perlu mengagregasi seluruh riwayat item_pesanan.
ALTER TABLE produk ADD COLUMN jumlah_terjual bigint NOT NULL DEFAULT 0;
-- Isi ulang (backfill) dari data yang sudah ada:
-- penghitung yang lahir belakangan harus disinkronkan dulu.
UPDATE produk pr
SET jumlah_terjual = t.unit
FROM (SELECT id_produk, SUM(kuantitas) AS unit
FROM item_pesanan
GROUP BY id_produk) AS t
WHERE t.id_produk = pr.id;
SELECT id, nama, harga, jumlah_terjual
FROM produk
ORDER BY id;
-- Penjaga penghitung: trigger function yang menambah jumlah_terjual
-- setiap kali satu baris item baru lahir.
CREATE FUNCTION catat_penjualan() RETURNS trigger AS $$
BEGIN
UPDATE produk SET jumlah_terjual = jumlah_terjual + NEW.kuantitas
WHERE id = NEW.id_produk;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_catat_penjualan
AFTER INSERT ON item_pesanan
FOR EACH ROW EXECUTE FUNCTION catat_penjualan();
-- Transaksi baru masuk lewat jalur biasa -- tanpa satu pun perintah
-- yang menyentuh jumlah_terjual secara manual.
INSERT INTO pesanan (id, tanggal, pelanggan_id)
VALUES (906, DATE '2026-02-18', 2);
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (906, 3, 2, 2500.00);
-- Penggaris naik dari 5 menjadi 7: trigger bekerja diam-diam.
SELECT id, nama, jumlah_terjual
FROM produk
ORDER BY id;Output yang Dihasilkan:
ALTER TABLE
UPDATE 3
id | nama | harga | jumlah_terjual
----+--------------+---------+----------------
1 | Buku Tulis | 4500.00 | 16
2 | Pulpen Hitam | 3000.00 | 13
3 | Penggaris | 2500.00 | 5
(3 rows)
CREATE FUNCTION
CREATE TRIGGER
INSERT 0 1
INSERT 0 1
id | nama | jumlah_terjual
----+--------------+----------------
1 | Buku Tulis | 16
2 | Pulpen Hitam | 13
3 | Penggaris | 7
(3 rows)Tiga tahap file itu adalah kontrak pemeliharaan penghitung. Backfill dulu — penghitung yang lahir belakangan harus disinkronkan dengan riwayat yang sudah ada, kalau tidak angkanya bohong sejak hari pertama. Lalu trigger berjaga: CREATE FUNCTION catat_penjualan() mendefinisikan pekerjaannya (tambah NEW.kuantitas ke produk yang tepat), dan CREATE TRIGGER ... AFTER INSERT menempelkan pekerjaan itu ke setiap baris baru di item_pesanan. Insert pesanan 906 lalu berjalan lewat jalur biasa, dan query penutup membuktikan jumlah_terjual Penggaris naik dari 5 ke 7 tanpa satu pun UPDATE manual. Sebagai bonus tak terduga, ketika pesanan 907 tiba pada file tabel ringkasan, trigger yang sama ikut bekerja di latar — Buku Tulis naik dari 12 ke 16 secara otomatis.
Perlu diakui dengan jujur: contoh trigger di atas hanya menjaga INSERT. Penghitung produksi yang utuh juga memasang penjaga untuk UPDATE (kuantitas berubah) dan DELETE (item dibatalkan) — satu celah saja, dan penghitung bergeser diam-diam.
Pola Lain dalam Satu Paragraf #
Dua pola tersisa cukup dikenali lewat bentuknya. Tabel pra-join menyimpan hasil join beberapa tabel sebagai satu tabel datar khusus pelaporan — misalnya laporan_pesanan berisi kolom nama pelanggan, nama produk, dan total sekaligus — dibangun ulang berkala seperti tabel ringkasan, dan hanya untuk beban baca yang sudah terbukti berat. Tabel histori menyimpan riwayat perubahan — riwayat_harga dengan harga lama, harga baru, dan tanggal berlakunya — gabungan antara pola snapshot dan kebutuhan audit; pola ini diotomasi penuh oleh trigger di section Triggers dan Events
.
Daftar Biaya Denormalisasi #
Setiap pola di atas punya tagihan. Ringkasan biayanya dalam satu tabel:
| Pola | Menyelesaikan | Harga yang dibayar |
|---|---|---|
| kolom duplikat manual | baca total tanpa agregasi | anomali update hidup kembali; semua jalur tulis wajib menjaga salinan |
| generated column | nilai turunan siap baca | hanya kolom baris yang sama; tetap butuh agregasi lintas baris |
| tabel ringkasan | laporan berat sekali baca | staleness antar penyegaran; siklus refresh harus dijadwalkan |
| penghitung trigger | agregat per induk tanpa scan | tiap INSERT/UPDATE/DELETE jadi lebih mahal; backfill wajib |
| tabel pra-join | laporan tanpa join sama sekali | duplikasi terbesar; penyegaran paling berat |
Aturan main terakhir: denormalisasi yang sehat tetap berangkat dari desain 3NF yang sehat. Urutannya tidak pernah dibalik — normalisasi dulu sampai tuntas, lalu salinan ditambahkan sadar-sadar di titik yang terukur membutuhkannya, dengan kontrak pemeliharaan untuk setiap salinan. Skema yang sudah teruji ini lalu tumbuh ke arah berikutnya: ketika satu database menampung lebih dari satu ranah — inventaris, laporan, data umum — tabel-tabelnya perlu dipisah ke dalam schema, dan resolusi nama tabel mulai ditentukan oleh search_path. Keduanya adalah materi chapter Schema dan Search Path .
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Basis data dinormalisasi Toko Maju Jaya: hasil akhir rantai 1NF-2NF-3NF.
-- Setiap fakta hidup satu kali; total pesanan TIDAK disimpan --
-- dihitung dari item saat dibutuhkan.
CREATE TABLE pelanggan (
id integer PRIMARY KEY,
nama text NOT NULL
);
CREATE TABLE produk (
id integer PRIMARY KEY,
nama text NOT NULL,
harga numeric(12, 2) NOT NULL
);
CREATE TABLE pesanan (
id integer PRIMARY KEY,
tanggal date NOT NULL,
pelanggan_id integer NOT NULL REFERENCES pelanggan (id)
);
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, 'Penggaris', 2500.00);
INSERT INTO pesanan (id, tanggal, pelanggan_id) VALUES
(901, DATE '2026-01-05', 1),
(902, DATE '2026-01-12', 2),
(903, DATE '2026-02-03', 1),
(904, DATE '2026-02-10', 2);
-- harga_saat_transaksi adalah snapshot harga saat transaksi --
-- pola dari chapter Junction Table yang tetap dipertahankan.
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi) VALUES
(901, 1, 10, 4500.00),
(901, 2, 5, 3000.00),
(902, 1, 2, 4500.00),
(903, 3, 4, 2500.00),
(904, 2, 6, 3000.00),
(904, 3, 1, 2500.00);
-- Total per pesanan dihitung, bukan disimpan: satu-satunya sumber
-- kebenaran adalah item_pesanan.
SELECT ps.id, ps.tanggal, pl.nama AS pelanggan,
SUM(i.kuantitas * i.harga_saat_transaksi) AS total
FROM pesanan ps
JOIN pelanggan pl ON pl.id = ps.pelanggan_id
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id, ps.tanggal, pl.nama
ORDER BY ps.id;
kolom-duplikat.sql
Unduh berkas-- Pola kolom duplikat: total disimpan di pesanan supaya laporan
-- tidak perlu join dan agregasi setiap kali dibuka.
ALTER TABLE pesanan ADD COLUMN total_tersimpan numeric(12, 2);
-- Pengisian awal: salin hasil agregasi ke kolom duplikat.
UPDATE pesanan ps
SET total_tersimpan = t.harga_total
FROM (SELECT id_pesanan, SUM(kuantitas * harga_saat_transaksi) AS harga_total
FROM item_pesanan
GROUP BY id_pesanan) AS t
WHERE t.id_pesanan = ps.id;
SELECT id, tanggal, total_tersimpan
FROM pesanan
ORDER BY id;
-- Kehidupan berjalan: pelanggan 901 menambah dua Pulpen
-- (kuantitas 5 menjadi 7) lewat UPDATE yang wajar.
UPDATE item_pesanan SET kuantitas = 7
WHERE id_pesanan = 901 AND id_produk = 2;
-- Kolom duplikat tidak ikut bergerak: total_tersimpan 901 masih
-- nilai lama, sementara perhitungan dari item sudah berubah.
SELECT ps.id, ps.total_tersimpan,
COALESCE(SUM(i.kuantitas * i.harga_saat_transaksi), 0) AS total_dihitung,
ps.total_tersimpan - COALESCE(SUM(i.kuantitas * i.harga_saat_transaksi), 0) AS selisih
FROM pesanan ps
LEFT JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY ps.id, ps.total_tersimpan
ORDER BY ps.id;
kolom-generated.sql
Unduh berkas-- Versi aman dari kolom terhitung: generated column.
-- Nilainya disimpan (STORED) untuk kecepatan baca, tetapi dihitung
-- dan dijaga oleh server -- bukan oleh disiplin petugas.
ALTER TABLE item_pesanan
ADD COLUMN subtotal numeric(12, 2)
GENERATED ALWAYS AS (kuantitas * harga_saat_transaksi) STORED;
-- subtotal selalu segar: baris 901-P02 otomatis mengikuti kuantitas 7
-- yang diubah pada file sebelumnya, tanpa ada UPDATE tambahan.
SELECT id_pesanan, id_produk, kuantitas, harga_saat_transaksi, subtotal
FROM item_pesanan
ORDER BY id_pesanan, id_produk;
-- Total per pesanan kini dihitung dari kolom yang sudah tersimpan --
-- agregasi tetap terjadi, tetapi tanpa perkalian per baris.
SELECT id_pesanan, SUM(subtotal) AS total
FROM item_pesanan
GROUP BY id_pesanan
ORDER BY id_pesanan;
penghitung-trigger.sql
Unduh berkas-- Pola penghitung: jumlah_terjual disimpan di produk supaya profil
-- produk tidak perlu mengagregasi seluruh riwayat item_pesanan.
ALTER TABLE produk ADD COLUMN jumlah_terjual bigint NOT NULL DEFAULT 0;
-- Isi ulang (backfill) dari data yang sudah ada:
-- penghitung yang lahir belakangan harus disinkronkan dulu.
UPDATE produk pr
SET jumlah_terjual = t.unit
FROM (SELECT id_produk, SUM(kuantitas) AS unit
FROM item_pesanan
GROUP BY id_produk) AS t
WHERE t.id_produk = pr.id;
SELECT id, nama, harga, jumlah_terjual
FROM produk
ORDER BY id;
-- Penjaga penghitung: trigger function yang menambah jumlah_terjual
-- setiap kali satu baris item baru lahir.
CREATE FUNCTION catat_penjualan() RETURNS trigger AS $$
BEGIN
UPDATE produk SET jumlah_terjual = jumlah_terjual + NEW.kuantitas
WHERE id = NEW.id_produk;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_catat_penjualan
AFTER INSERT ON item_pesanan
FOR EACH ROW EXECUTE FUNCTION catat_penjualan();
-- Transaksi baru masuk lewat jalur biasa -- tanpa satu pun perintah
-- yang menyentuh jumlah_terjual secara manual.
INSERT INTO pesanan (id, tanggal, pelanggan_id)
VALUES (906, DATE '2026-02-18', 2);
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (906, 3, 2, 2500.00);
-- Penggaris naik dari 5 menjadi 7: trigger bekerja diam-diam.
SELECT id, nama, jumlah_terjual
FROM produk
ORDER BY id;
tabel-ringkasan.sql
Unduh berkas-- Pola tabel ringkasan: laporan bulanan disajikan dari satu tabel kecil,
-- bukan dari agregasi join tiga tabel berisi seluruh riwayat.
CREATE TABLE penjualan_bulanan (
bulan date PRIMARY KEY,
jumlah_pesanan integer NOT NULL,
total_pendapatan numeric(14, 2) NOT NULL
);
INSERT INTO penjualan_bulanan (bulan, jumlah_pesanan, total_pendapatan)
SELECT date_trunc('month', ps.tanggal)::date,
count(DISTINCT ps.id),
SUM(i.kuantitas * i.harga_saat_transaksi)
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
-- Laporan bulanan kini sekali baca, tanpa join.
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
ORDER BY bulan;
-- Kehidupan berjalan: pesanan baru masuk lewat jalur biasa.
INSERT INTO pesanan (id, tanggal, pelanggan_id)
VALUES (907, DATE '2026-02-20', 1);
INSERT INTO item_pesanan (id_pesanan, id_produk, kuantitas, harga_saat_transaksi)
VALUES (907, 1, 4, 4500.00);
-- Tabel ringkasan tidak ikut bergerak: Februari tersimpan 35500.00,
-- sementara hitungan dari data hidup sudah 53500.00.
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
WHERE bulan = DATE '2026-02-01';
SELECT date_trunc('month', ps.tanggal)::date AS bulan,
count(DISTINCT ps.id) AS jumlah_pesanan,
SUM(i.kuantitas * i.harga_saat_transaksi) AS total_pendapatan
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
-- Siklus penyegaran: kosongkan, isi ulang dari sumber kebenaran.
TRUNCATE penjualan_bulanan;
INSERT INTO penjualan_bulanan (bulan, jumlah_pesanan, total_pendapatan)
SELECT date_trunc('month', ps.tanggal)::date,
count(DISTINCT ps.id),
SUM(i.kuantitas * i.harga_saat_transaksi)
FROM pesanan ps
JOIN item_pesanan i ON i.id_pesanan = ps.id
GROUP BY date_trunc('month', ps.tanggal)::date
ORDER BY 1;
SELECT bulan, jumlah_pesanan, total_pendapatan
FROM penjualan_bulanan
ORDER BY bulan;
tolak-update-generated.sql
Unduh berkas-- Kolom duplikat manual bisa dibekukan oleh disiplin; generated column
-- dibekukan oleh server. UPDATE langsung ditolak.
UPDATE item_pesanan SET subtotal = 0.01
WHERE id_pesanan = 901 AND id_produk = 1;