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:

JenisPertanyaan yang dijawabContoh 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:

text
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:

text
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:

sql pelanggaran.sql
-- 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:

text
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:

sql evolusi.sql
-- 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:

text
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:

PerubahanEnumDomainCompositeArray/Range
Tambah nilai/atributMurah (ADD VALUE)n/aADD ATTRIBUTE — tulis ulang tabel di beberapa versin/a
Ubah aturanSusah (tidak ada REMOVE VALUE)ALTER DOMAIN ... VALIDATE perlu pemeriksaan dataTerbatasGanti tipe kolom
HapusTidak ada DROP VALUEMudah bila tak terpakaiMenyusun ulang kolom pemakaiKonversi 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 unnest di semua tempat tanpa index dan tanpa integritas.

Daftar Keputusan Final #

Rangkuman satu tabel untuk membawa pulang seluruh section ini:

KondisiPilihanAlasan
Daftar tetap kecil dan terurut, evolusi jarangEnumRingkas, terbaca, urutan bawaan
Aturan kolom dipakai 3+ tabelDomainSatu definisi, penegakan merata
Kelompok kolom sebagai satu nilai (alamat, koordinat)CompositeSemantik utuh, tanpa tabel baru
Data berupa interval dengan query overlapRange typeOperator && @> dan index GiST
Daftar pelengkap yang diquery utuhArrayPraktis, didukung GIN
Daftar yang berelasi atau diagregasi per elemenJunction tableIntegritas, index, relasi penuh
Ragam semua di atasKolom biasa + CHECKPaling 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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');