Tujuh chapter sebelumnya memperkenalkan seluruh perangkat tipe kustom PostgreSQL: array, composite, enum, domain, dan range. Semuanya tampak kuat — tetapi kekuatan itu datang dengan biaya evolusi dan kebiasaan desain yang harus dipikirkan matang sebelum dipakai. Chapter penutup ini tidak mengajarkan sintaks baru; ia melatih penilaian. Kapan custom type benar-benar membayar biayanya, dan kapan kolom biasa dengan constraint justru pilihan yang lebih jernih?
Empat Kandidat dengan Peran yang Berbeda #
Keputusan dimulai dari mengenali bahwa keempat jenis tipe kustom menjawab pertanyaan yang berbeda. Tabel berikut merangkum perannya masing-masing:
| Jenis | Pertanyaan yang dijawab | Contoh wajar |
|---|---|---|
| Enum | “Kolom ini hanya boleh berisi daftar tetap yang terurut?” | status pengiriman, tahap wawancara |
| Domain | “Aturan kolom ini dipakai ulang di banyak tabel?” | harga non-negatif, kode pos 5 digit |
| Composite | “Sekelompok kolom selalu bergerak bersama sebagai satu nilai?” | alamat, koordinat, rentang waktu |
| Range | “Data ini pada dasarnya adalah interval, bukan dua angka terpisah?” | jadwal, masa berlaku tarif |
| Array | “Satu entitas memegang daftar nilai sederhana yang jarang diquery satu-satu?” | tag produk, daftar telepon |
Aturan praktisnya: enum dan domain memperkuat kolom yang sudah ada, composite dan range menggabungkan beberapa kolom menjadi satu nilai bermakna, sedangkan array menyimpan daftar. Junction table tidak muncul di tabel ini karena ia bukan tipe — ia struktur relasi — tetapi dia tetap pesaing utama array, dan kita akan menimbangnya lagi di bagian akhir.
Ketiganya Bekerja Bersama dalam Satu Skema #
Tiga jenis tipe kustom bisa hidup berdampingan pada satu tabel yang sama, masing-masing menjawab kebutuhan kolomnya. Tabel pengiriman berikut memakai composite alamat untuk tujuan, domain harga_rp untuk ongkir, dan enum status_kirim untuk status:
-- Tiga custom type yang berbeda peran: enum, domain, dan composite.
CREATE TYPE status_kirim AS ENUM ('draf', 'dikemas', 'dikirim', 'tiba');
CREATE DOMAIN harga_rp AS numeric(12, 2)
CHECK (VALUE >= 0 AND VALUE <= 999999999.99);
CREATE TYPE alamat AS (
jalan text,
kota text,
kode_pos text
);
CREATE TABLE pengiriman (
id integer PRIMARY KEY,
tujuan alamat NOT NULL,
ongkir harga_rp NOT NULL,
status status_kirim NOT NULL DEFAULT 'draf'
);
INSERT INTO pengiriman (id, tujuan, ongkir, status) VALUES
(1, ('Jl. Merdeka 10', 'Bandung', '40111'), 15000.00, 'tiba'),
(2, ('Jl. Sudirman 5', 'Jakarta', '10210'), 25000.00, 'dikirim');
SELECT id,
(tujuan).kota AS kota_tujuan,
ongkir,
status
FROM pengiriman
ORDER BY id;
-- Katalog ringkas tipe kustom di database ini.
SELECT t.typname, t.typtype AS jenis
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
AND t.typtype IN ('e', 'd', 'c')
AND t.typname NOT LIKE 'pg\_%'
ORDER BY t.typtype, t.typname;Output yang Dihasilkan:
CREATE TYPE
CREATE DOMAIN
CREATE TYPE
CREATE TABLE
INSERT 0 2
id | kota_tujuan | ongkir | status
----+-------------+----------+---------
1 | Bandung | 15000.00 | tiba
2 | Jakarta | 25000.00 | dikirim
(2 rows)
typname | jenis
--------------+-------
alamat | c
pengiriman | c
harga_rp | d
status_kirim | e
(4 rows)Dua pengamatan dari hasil eksekusi. Pertama, query membaca isi composite dengan sintaks (tujuan).kota — tanda kurung wajib agar parser tidak menganggapnya nama tabel. Kedua, query katalog di akhir file membedah pg_type dengan kolom typtype: e untuk enum, d untuk domain, c untuk composite — cara cepat mengaudit tipe kustom apa saja yang sudah terpasang di sebuah database sebelum menambah yang baru.
Domain: Aturan Sekali Tulis, Dipakai Berkali-kali #
Kekuatan terbesar domain adalah pemakaian ulang. Bandingkan dua tabel berikut: yang pertama menulis ulang CHECK yang sama, yang kedua cukup menyebut domain:
-- CHECK constraint di tabel: aturan yang sama harus ditulis ulang.
CREATE TABLE pembayaran_a (
id integer PRIMARY KEY,
jumlah numeric(12, 2) CHECK (jumlah >= 0 AND jumlah <= 999999999.99)
);
-- Domain: aturan sekali definisi, dipakai di mana saja.
CREATE TABLE pembayaran_b (
id integer PRIMARY KEY,
jumlah harga_rp
);
INSERT INTO pembayaran_a (id, jumlah) VALUES (1, 50000.00);
INSERT INTO pembayaran_b (id, jumlah) VALUES (1, 50000.00);
SELECT 'pembayaran_a' AS tabel, jumlah FROM pembayaran_a WHERE id = 1
UNION ALL
SELECT 'pembayaran_b', jumlah FROM pembayaran_b WHERE id = 1;Output yang Dihasilkan:
CREATE TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 1
tabel | jumlah
--------------+----------
pembayaran_a | 50000.00
pembayaran_b | 50000.00
(2 rows)Kedua tabel menerima nilai yang sama persis — dari sudut pandang hasil, keduanya setara. Bedanya muncul saat tabel ketiga dan keempat lahir: versi CHECK menuntut aturan disalin ulang (dan diperiksa sinkron selamanya), sedangkan versi domain tinggal menulis harga_rp. Penegakan aturannya pun identik:
-- Domain menolak nilai negatif di tabel mana pun yang memakainya.
INSERT INTO pengiriman (id, tujuan, ongkir, status) VALUES
(3, ('Jl. Malioboro 21', 'Yogyakarta', '55111'), -5000.00, 'draf');Output yang Dihasilkan:
psql:pelanggaran.sql:3: ERROR: value for domain harga_rp violates check constraint "harga_rp_check"Pesan error menyebut nama domain harga_rp — petunjuk debugging yang justru lebih baik daripada CHECK anonim: pembaca error langsung tahu aturan mana yang dilanggar dan di mana definisinya dicari.
Biaya Evolusi: Enum yang Bertambah #
Keputusan custom type bukan keputusan sekali jalan — tipe akan diminta berubah. Enum adalah kasus paling ringan: menambah nilai baru aman dan murah, dengan satu batasan halus:
-- Menambah nilai enum baru: murah dan aman, tapi nilainya
-- hanya bisa dipakai setelah commit transaksi ini.
ALTER TYPE status_kirim ADD VALUE IF NOT EXISTS 'batal';
SELECT enumlabel AS nilai, enumsortorder AS urutan
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'status_kirim'
ORDER BY enumsortorder;Output yang Dihasilkan:
ALTER TYPE
nilai | urutan
---------+--------
draf | 1
dikemas | 2
dikirim | 3
tiba | 4
batal | 5
(5 rows)Nilai batal masuk di urutan paling akhir — konsisten dengan perilaku enum yang mengikuti urutan deklarasi. Batasannya: nilai yang baru ditambah tidak bisa langsung dipakai dalam transaksi yang sama (misalnya WHERE status = 'batal' di transaksi yang menambahkannya akan ditolak), karena nilai tersebut baru benar-benar ada setelah COMMIT. Bandingkan dengan evolusi tabel biasa: ALTER TABLE ADD COLUMN jauh lebih bebas, dan data lamaran relasi tetap bisa diquery tanpa tipe khusus.
Perbandingan biaya evolusi untuk semua jenis:
| Perubahan | Enum | Domain | Composite | Array/Range |
|---|---|---|---|---|
| Tambah nilai/atribut | Murah (ADD VALUE) | n/a | ADD ATTRIBUTE — tulis ulang tabel di beberapa versi | n/a |
| Ubah aturan | Susah (tidak ada REMOVE VALUE) | ALTER DOMAIN ... VALIDATE perlu pemeriksaan data | Terbatas | Ganti tipe kolom |
| Hapus | Tidak ada DROP VALUE | Mudah bila tak terpakai | Menyusun ulang kolom pemakai | Konversi data |
Baris “Ubah aturan” dan “Hapus” itulah alasan enum sebaiknya hanya untuk daftar yang benar-benar tertutup. Status pengiriman bertambah batal? Aman. Daftar kategori produk yang setiap bulan lahir dan mati? Itu bukan enum — itu tabel referensi dengan foreign key.
Junction Table Masih Rajanya Data Berelasi #
Satu penyeimbang terakhir sebelum tergiur memasukkan semua hubungan ke array. Ketika daftar nilai perlu diquery, dihitung, atau berelasi ke tabel lain, array kalah dari junction table yang kita rancang di Section Relational Database Design. Aturan pembedanya:
- Array cukup bila daftar itu label pelengkap: tag produk yang paling sering dipakai utuh (
WHERE tag @> ARRAY['diskon']), nomor telepon cadangan yang jarang diakses satu-satu. - Junction table wajib bila elemen daftarnya adalah relasi bisnis: produk dalam pesanan (ada kuantitas dan harga snapshot), siswa dalam kelas (ada nilai), penulis buku (ada urutan). Query “berapa pesanan memuat produk X”, “rata-rata nilai per kelas”, dan “buku kedua penulis Y” adalah pertanyaan relasi — menjawabnya dari array berarti
unnestdi semua tempat tanpa index dan tanpa integritas.
Daftar Keputusan Final #
Rangkuman satu tabel untuk membawa pulang seluruh section ini:
| Kondisi | Pilihan | Alasan |
|---|---|---|
| Daftar tetap kecil dan terurut, evolusi jarang | Enum | Ringkas, terbaca, urutan bawaan |
| Aturan kolom dipakai 3+ tabel | Domain | Satu definisi, penegakan merata |
| Kelompok kolom sebagai satu nilai (alamat, koordinat) | Composite | Semantik utuh, tanpa tabel baru |
| Data berupa interval dengan query overlap | Range type | Operator && @> dan index GiST |
| Daftar pelengkap yang diquery utuh | Array | Praktis, didukung GIN |
| Daftar yang berelasi atau diagregasi per elemen | Junction table | Integritas, index, relasi penuh |
| Ragam semua di atas | Kolom biasa + CHECK | Paling fleksibel berevolusi |
Prinsip penutupnya satu kalimat: custom type memperketat makna, tabel menghubungkan data. Mulailah dari kolom biasa, naikkan ke tipe kustom hanya ketika makna yang diperketat benar-benar dipakai berulang — dan selalu hitung biaya evolusinya sebelum menandatangani. Dengan seluruh perangkat tipe dan struktur kini lengkap, perjalanan berikutnya menyentuh kemampuan pencarian teks yang mengalahkan LIKE: Section Full-Text Search dimulai dari konsepnya di chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
domain-vs-check.sql
Unduh berkas-- CHECK constraint di tabel: aturan yang sama harus ditulis ulang.
CREATE TABLE pembayaran_a (
id integer PRIMARY KEY,
jumlah numeric(12, 2) CHECK (jumlah >= 0 AND jumlah <= 999999999.99)
);
-- Domain: aturan sekali definisi, dipakai di mana saja.
CREATE TABLE pembayaran_b (
id integer PRIMARY KEY,
jumlah harga_rp
);
INSERT INTO pembayaran_a (id, jumlah) VALUES (1, 50000.00);
INSERT INTO pembayaran_b (id, jumlah) VALUES (1, 50000.00);
SELECT 'pembayaran_a' AS tabel, jumlah FROM pembayaran_a WHERE id = 1
UNION ALL
SELECT 'pembayaran_b', jumlah FROM pembayaran_b WHERE id = 1;
evolusi.sql
Unduh berkas-- Menambah nilai enum baru: murah dan aman, tapi nilainya
-- hanya bisa dipakai setelah commit transaksi ini.
ALTER TYPE status_kirim ADD VALUE IF NOT EXISTS 'batal';
SELECT enumlabel AS nilai, enumsortorder AS urutan
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'status_kirim'
ORDER BY enumsortorder;
katalog-jenis.sql
Unduh berkas-- Tiga custom type yang berbeda peran: enum, domain, dan composite.
CREATE TYPE status_kirim AS ENUM ('draf', 'dikemas', 'dikirim', 'tiba');
CREATE DOMAIN harga_rp AS numeric(12, 2)
CHECK (VALUE >= 0 AND VALUE <= 999999999.99);
CREATE TYPE alamat AS (
jalan text,
kota text,
kode_pos text
);
CREATE TABLE pengiriman (
id integer PRIMARY KEY,
tujuan alamat NOT NULL,
ongkir harga_rp NOT NULL,
status status_kirim NOT NULL DEFAULT 'draf'
);
INSERT INTO pengiriman (id, tujuan, ongkir, status) VALUES
(1, ('Jl. Merdeka 10', 'Bandung', '40111'), 15000.00, 'tiba'),
(2, ('Jl. Sudirman 5', 'Jakarta', '10210'), 25000.00, 'dikirim');
SELECT id,
(tujuan).kota AS kota_tujuan,
ongkir,
status
FROM pengiriman
ORDER BY id;
-- Katalog ringkas tipe kustom di database ini.
SELECT t.typname, t.typtype AS jenis
FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE n.nspname = 'public'
AND t.typtype IN ('e', 'd', 'c')
AND t.typname NOT LIKE 'pg\_%'
ORDER BY t.typtype, t.typname;
pelanggaran.sql
Unduh berkas-- Domain menolak nilai negatif di tabel mana pun yang memakainya.
INSERT INTO pengiriman (id, tujuan, ongkir, status) VALUES
(3, ('Jl. Malioboro 21', 'Yogyakarta', '55111'), -5000.00, 'draf');