Chapter Range dan Multirange di section Tipe Data
memperkenalkan bentuk [mulai, selesai) dan contoh booking ruangan dengan exclusion constraint. Chapter ini membedah mesinnya lebih dalam: dua cara membangun rentang (literal dan constructor), aturan normalisasi yang membedakan tipe diskrit dari kontinu — beserta kejutan upper() yang mengembalikan angka yang tidak pernah Anda tulis — rombongan operator lengkap termasuk <<, >>, dan -|-, operasi himpunan + * - beserta batas kerasnya, dan aggregate range_agg() yang melebur banyak baris rentang menjadi satu multirange. Studi kasus penutupnya khas bisnis: tarif ongkir yang berlaku per periode tanggal.
Cara Membangun Rentang: Literal dan Constructor #
Rentang dibangun lewat cast literal '[a,b)'::int4range atau constructor int4range(a, b, '[]') — pola batasnya dikodekan empat karakter: [ atau ( untuk batas bawah, ] atau ) untuk batas atas:
-- literal: '[' = batas bawah inklusif, ')' = batas atas eksklusif
SELECT '[1,5)'::int4range AS literal_eksklusif,
'[1,5]'::int4range AS literal_inklusif;
-- constructor dengan pola batas eksplisit sebagai argumen ketiga
SELECT int4range(1, 5, '[)') AS fungsi_eksklusif,
int4range(1, 5, '[]') AS fungsi_inklusif;
-- tipe DISKRIT (integer, date) dinormalisasi ke bentuk [a,b)
SELECT int4range(1, 5, '[]') AS int4_dinormalisasi,
daterange(DATE '2026-01-01', DATE '2026-03-31', '[]') AS date_dinormalisasi;
-- tipe KONTINU (numeric, timestamp) mempertahankan pola batas aslinya
SELECT numrange(1.0, 5.0, '[]') AS num_tetap_inklusif,
numrange(1.0, 5.0, '(]') AS num_batas_bawah_eksklusif;
-- NULL sebagai batas berarti "tak berujung"
SELECT int4range(NULL, 10) AS tanpa_batas_bawah,
daterange(DATE '2026-07-01', NULL) AS tanpa_batas_atas;Output yang Dihasilkan:
literal_eksklusif | literal_inklusif
-------------------+------------------
[1,5) | [1,6)
(1 row)
fungsi_eksklusif | fungsi_inklusif
------------------+-----------------
[1,5) | [1,6)
(1 row)
int4_dinormalisasi | date_dinormalisasi
--------------------+-------------------------
[1,6) | [2026-01-01,2026-04-01)
(1 row)
num_tetap_inklusif | num_batas_bawah_eksklusif
--------------------+---------------------------
[1.0,5.0] | (1.0,5.0]
(1 row)
tanpa_batas_bawah | tanpa_batas_atas
-------------------+------------------
(,10) | [2026-07-01,)
(1 row)Literal '[1,5]' dan constructor int4range(1,5,'[]') sama-sama meminta kedua batas inklusif — tetapi lihat hasilnya: int4_dinormalisasi tercetak [1,6). Inilah aturan penting yang membedakan dua keluarga tipe. Untuk tipe diskrit seperti integer dan date, PostgreSQL selalu menyimpan bentuk kanonik [a,b): [1,5] setara [1,6) karena nilai 5 adalah elemen terakhir sebelum 6. Untuk tipe kontinu seperti numeric dan timestamp, tidak ada “nilai berikutnya” yang bisa dijadikan patokan — di antara 5.0 dan nilai sesudahnya masih ada tak berhingga angka — sehingga pola batas asli dipertahankan apa adanya: numrange(1.0, 5.0, '[]') tetap inklusif di kedua ujung. Baris terakhir memperlihatkan batas NULL yang dibaca “tak berujung”: int4range(NULL, 10) berarti “semua nilai di bawah 10”.
Membaca Batas: lower, upper, dan Keterangan Inklusivitas #
Lima fungsi pelapor membaca kembali anatomi sebuah rentang — dan dua di antaranya menyimpan kejutan bagi yang lupa normalisasi:
SELECT lower('[1,5)'::int4range) AS batas_bawah,
upper('[1,5)'::int4range) AS batas_atas,
lower_inc('[1,5)'::int4range) AS bawah_inklusif,
upper_inc('[1,5)'::int4range) AS atas_inklusif,
isempty('[1,1)'::int4range) AS kosong;
-- upper() melaporkan batas SETELAH normalisasi, bukan angka input
SELECT upper('[1,5]'::int4range) AS upper_setelah_normalisasi,
lower_inc('[1,5]'::int4range) AS bawah_tetap_inklusif;
-- pada tipe kontinu, batas atas inklusif tetap inklusif
SELECT upper(numrange(1.0, 5.0, '[]')) AS num_batas_atas,
upper_inc(numrange(1.0, 5.0, '[]')) AS num_atas_inklusif;
-- batas tak berujung terbaca sebagai NULL
SELECT lower(int4range(NULL, 10)) AS bawah_tanpa_ujung,
upper(int4range(NULL, 10)) AS atas_ada;Output yang Dihasilkan:
batas_bawah | batas_atas | bawah_inklusif | atas_inklusif | kosong
-------------+------------+----------------+---------------+--------
1 | 5 | t | f | t
(1 row)
upper_setelah_normalisasi | bawah_tetap_inklusif
---------------------------+----------------------
6 | t
(1 row)
num_batas_atas | num_atas_inklusif
----------------+-------------------
5.0 | t
(1 row)
bawah_tanpa_ujung | atas_ada
-------------------+----------
| 10
(1 row)lower() dan upper() melaporkan batas numeriknya, lower_inc()/upper_inc() melaporkan inklusivitas tiap batas, dan isempty() mengenali rentang kosong seperti [1,1). Kejutannya ada di query kedua: upper('[1,5]'::int4range) mengembalikan 6, bukan 5 — fungsi-fungsi ini membaca bentuk pasca-normalisasi yang benar-benar tersimpan, bukan teks input Anda. Query ketiga menegaskan pengecualiannya: pada numrange yang kontinu, batas atas inklusif tetap terbaca inklusif (upper_inc = t) dengan upper() = 5.0. Query keempat memperlihatkan batas tak berujung yang terbaca NULL — perilaku yang membuat upper(int4range(NULL, 10)) harus dibedakan dari rentang yang benar-benar rusak, misalkan lewat IS NULL.
Operator Rentang #
Satu keluarga operator menjawab hampir semua pertanyaan tentang hubungan dua rentang — dan hubungan rentang dengan satu nilai:
| Operator | Makna |
|---|---|
r @> x | rentang r memuat nilai x atau rentang x |
r <@ x | rentang r terkandung dalam x |
r && s | r dan s beririsan (overlap) |
r << s | r sepenuhnya di kiri s (strictly left) |
r >> s | r sepenuhnya di kanan s (strictly right) |
r -|- s | r dan s bersebelahan (adjacent: ujung bersama, tanpa tumpang tindih) |
Semuanya bekerja serentak dalam satu query:
SELECT int4range(1, 10) @> 5 AS berisi_nilai,
int4range(1, 10) @> int4range(2, 5) AS berisi_rentang,
int4range(2, 5) <@ int4range(1, 10) AS terkandung,
int4range(1, 10) && int4range(5, 15) AS overlap,
int4range(1, 5) << int4range(6, 9) AS strictly_kiri,
int4range(6, 9) >> int4range(1, 5) AS strictly_kanan,
int4range(1, 5) -|- int4range(5, 9) AS bersebelahan;Output yang Dihasilkan:
berisi_nilai | berisi_rentang | terkandung | overlap | strictly_kiri | strictly_kanan | bersebelahan
--------------+----------------+------------+---------+---------------+----------------+--------------
t | t | t | t | t | t | t
(1 row)@> 5 menguji satu nilai, @> int4range(2,5) menguji rentang penuh — operator yang sama untuk dua granularitas. << dan >> memisahkan rentang yang benar-benar terputus ([1,5) di kiri [6,9)), dan -|- mengenali yang bersebelahan rapat: [1,5) berakhir tepat di tempat [5,9) dimulai. Operator && adalah bintang utama pencegahan bentrok jadwal — dan bila dipasangkan exclusion constraint dengan btree_gist, database menolak dua rentang yang bertabrakan sebelum sempat tersimpan, sebagaimana didemokan di chapter Exclusion Constraints
.
Operasi Himpunan: union, intersection, difference #
Operator aritmetika +, *, dan - memperlakukan rentang seperti himpunan — dengan satu syarat yang dipegang teguh PostgreSQL: hasil + dan - harus tetap satu rentang yang menyatu:
-- union (+): hasil harus menyatu menjadi SATU rentang
SELECT int4range(1, 10) + int4range(5, 15) AS union_menyatu;
-- intersection (*): bagian yang tumpang tindih
SELECT int4range(1, 10) * int4range(5, 15) AS irisan;
-- difference (-): sisi kiri dikurangi bagian yang tumpang tindih
SELECT int4range(1, 10) - int4range(5, 15) AS selisih_ujung;Output yang Dihasilkan:
union_menyatu
---------------
[1,15)
(1 row)
irisan
--------
[5,10)
(1 row)
selisih_ujung
---------------
[1,5)
(1 row)+ menggabungkan [1,10) dan [5,15) yang tumpang tindih menjadi [1,15). * mengambil irisan [5,10). - mengurangi [5,15) dari [1,10) dan menyisakan [1,5) — hasilnya menyatu karena yang dikurangi adalah ujung kanan. Batas kerasnya muncul begitu hasil tak mungkin menyatu:
-- union dua rentang yang TERPISAH: hasil tidak mungkin menyatu
SELECT int4range(1, 3) + int4range(5, 7) AS union_terpisah;Output yang Dihasilkan:
psql:range-error.sql:2: ERROR: result of range union would not be contiguous[1,3) dan [5,7) terpisah oleh celah [3,5) — tidak ada satu rentang valid yang bisa mewakili keduanya sekaligus, dan PostgreSQL menolak daripada menebak. Kasus saudaranya menunggu di operasi -: mengurangi rentang dari tengah (int4range(1,10) - int4range(3,5)) akan memecah hasil menjadi dua bagian, sehingga ditolak dengan error serupa, result of range difference would not be contiguous. Untuk kumpulan rentang yang memang tidak menyatu, PostgreSQL menyediakan jawabannya: multirange — satu nilai yang boleh memuat banyak rentang, yang kita temui di studi kasus berikutnya.
Studi Kasus: Tarif Berlaku per Periode #
Kini seluruh peralatan dipakai untuk satu masalah nyata: tarif ongkir Toko Maju Jaya naik per periode, dan query harian harus menjawab “berapa tarif yang berlaku hari ini?”:
-- use case: tarif ongkir yang berlaku per periode waktu
CREATE TABLE tarif (
layanan text NOT NULL,
periode daterange NOT NULL,
harga_per_kg numeric(10, 2) NOT NULL,
PRIMARY KEY (layanan, periode)
);
INSERT INTO tarif (layanan, periode, harga_per_kg) VALUES
('reguler', daterange(DATE '2026-01-01', DATE '2026-04-01'), 12000),
('reguler', daterange(DATE '2026-04-01', DATE '2026-07-01'), 13500),
('ekspres', daterange(DATE '2026-01-01', DATE '2026-07-01'), 25000);
-- tarif mana saja yang berlaku pada 15 Maret 2026?
SELECT layanan, harga_per_kg, periode
FROM tarif
WHERE periode @> DATE '2026-03-15'
ORDER BY layanan;
-- dan pada 20 April 2026, saat kenaikan sudah berlaku?
SELECT layanan, harga_per_kg
FROM tarif
WHERE periode @> DATE '2026-04-20'
ORDER BY layanan;
-- range_merge: menggabungkan DUA rentang bertetangga menjadi satu rentang besar
SELECT range_merge(daterange(DATE '2026-01-01', DATE '2026-04-01'),
daterange(DATE '2026-04-01', DATE '2026-07-01')) AS semester;
-- multirange: kumpulan rentang yang tidak harus menyatu
CREATE TABLE jadwal_libur (
id integer PRIMARY KEY,
periode daterange NOT NULL
);
INSERT INTO jadwal_libur (id, periode) VALUES
(1, daterange(DATE '2026-01-01', DATE '2026-01-08')),
(2, daterange(DATE '2026-03-01', DATE '2026-03-05')),
(3, daterange(DATE '2026-01-05', DATE '2026-01-10'));
-- range_agg: melebur banyak rentang menjadi multirange (yang bertumpuk menyatu)
SELECT range_agg(periode) AS semua_libur
FROM jadwal_libur;
-- operator keanggotaan bekerja juga pada multirange
WITH libur AS (
SELECT range_agg(periode) AS periode_libur FROM jadwal_libur
)
SELECT DATE '2026-03-02' <@ periode_libur AS maret_libur,
DATE '2026-02-14' <@ periode_libur AS februari_libur
FROM libur;
-- literal dan constructor multirange
SELECT '{[1,3),[5,7)}'::int4multirange AS literal_multirange,
int4multirange(int4range(1, 3), int4range(5, 7)) AS konstruktor_multirange;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
layanan | harga_per_kg | periode
---------+--------------+-------------------------
ekspres | 25000.00 | [2026-01-01,2026-07-01)
reguler | 12000.00 | [2026-01-01,2026-04-01)
(2 rows)
layanan | harga_per_kg
---------+--------------
ekspres | 25000.00
reguler | 13500.00
(2 rows)
semester
-------------------------
[2026-01-01,2026-07-01)
(1 row)
CREATE TABLE
INSERT 0 3
semua_libur
---------------------------------------------------
{[2026-01-01,2026-01-10),[2026-03-01,2026-03-05)}
(1 row)
maret_libur | februari_libur
-------------+----------------
t | f
(1 row)
literal_multirange | konstruktor_multirange
--------------------+------------------------
{[1,3),[5,7)} | {[1,3),[5,7)}
(1 row)Tabel tarif menyimpan periode daterange sebagai bagian primary key — dua periode sama layanan boleh ada selama rentangnya berbeda. Query WHERE periode @> DATE '2026-03-15' menjawab pertanyaan utama: pada 15 Maret, tarif reguler masih 12.000 dan ekspres 25.000; pada 20 April, reguler sudah naik ke 13.500. Bandingkan dengan desain dua kolom mulai/selesai: di sana Anda menulis mulai <= X AND selesai >= X dengan risiko salah pembanding di batas, sedangkan di sini inklusivitas terkunci di tipe dan satu operator @> menuntaskan semuanya.
Bagian kedua file membawa agregasi. range_merge() menggabungkan dua rentang bertetangga menjadi semester penuh [2026-01-01,2026-07-01). Lalu range_agg() — aggregate bawaan sejak PostgreSQL 14 — melebur banyak baris menjadi satu multirange: tiga baris jadwal libur, dua di antaranya bertumpuk di Januari, menjadi {[2026-01-01,2026-01-10),[2026-03-01,2026-03-05)} — yang bertumpuk otomatis menyatu, yang terpisah tetap terpisah. Operator keanggotaan <@ bekerja pada multirange persis seperti pada range: 2 Maret terdeteksi libur, 14 Februari tidak. Baris penutup memperlihatkan dua cara membangun multirange secara langsung: literal '{[1,3),[5,7)}' dan constructor int4multirange(...) — jawaban resmi atas error union yang tidak menyatu di bagian sebelumnya.
Empat tipe kustom section ini — composite, enum, domain, range — kini lengkap di tangan Anda, masing-masing menyelesaikan satu kelas masalah. Persoalan terakhir bersifat keputusan, bukan teknis: kapan custom type benar-benar sepadan, dan kapan ia hanya menambah lapisan indirection di atas kolom biasa? Chapter penutup section ini menimbangnya jujur — termasuk peringatan soal over-engineering.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
fungsi-range.sql
Unduh berkasSELECT lower('[1,5)'::int4range) AS batas_bawah,
upper('[1,5)'::int4range) AS batas_atas,
lower_inc('[1,5)'::int4range) AS bawah_inklusif,
upper_inc('[1,5)'::int4range) AS atas_inklusif,
isempty('[1,1)'::int4range) AS kosong;
-- upper() melaporkan batas SETELAH normalisasi, bukan angka input
SELECT upper('[1,5]'::int4range) AS upper_setelah_normalisasi,
lower_inc('[1,5]'::int4range) AS bawah_tetap_inklusif;
-- pada tipe kontinu, batas atas inklusif tetap inklusif
SELECT upper(numrange(1.0, 5.0, '[]')) AS num_batas_atas,
upper_inc(numrange(1.0, 5.0, '[]')) AS num_atas_inklusif;
-- batas tak berujung terbaca sebagai NULL
SELECT lower(int4range(NULL, 10)) AS bawah_tanpa_ujung,
upper(int4range(NULL, 10)) AS atas_ada;
konstruksi-range.sql
Unduh berkas-- literal: '[' = batas bawah inklusif, ')' = batas atas eksklusif
SELECT '[1,5)'::int4range AS literal_eksklusif,
'[1,5]'::int4range AS literal_inklusif;
-- constructor dengan pola batas eksplisit sebagai argumen ketiga
SELECT int4range(1, 5, '[)') AS fungsi_eksklusif,
int4range(1, 5, '[]') AS fungsi_inklusif;
-- tipe DISKRIT (integer, date) dinormalisasi ke bentuk [a,b)
SELECT int4range(1, 5, '[]') AS int4_dinormalisasi,
daterange(DATE '2026-01-01', DATE '2026-03-31', '[]') AS date_dinormalisasi;
-- tipe KONTINU (numeric, timestamp) mempertahankan pola batas aslinya
SELECT numrange(1.0, 5.0, '[]') AS num_tetap_inklusif,
numrange(1.0, 5.0, '(]') AS num_batas_bawah_eksklusif;
-- NULL sebagai batas berarti "tak berujung"
SELECT int4range(NULL, 10) AS tanpa_batas_bawah,
daterange(DATE '2026-07-01', NULL) AS tanpa_batas_atas;
operasi-range.sql
Unduh berkas-- union (+): hasil harus menyatu menjadi SATU rentang
SELECT int4range(1, 10) + int4range(5, 15) AS union_menyatu;
-- intersection (*): bagian yang tumpang tindih
SELECT int4range(1, 10) * int4range(5, 15) AS irisan;
-- difference (-): sisi kiri dikurangi bagian yang tumpang tindih
SELECT int4range(1, 10) - int4range(5, 15) AS selisih_ujung;
operator-range.sql
Unduh berkasSELECT int4range(1, 10) @> 5 AS berisi_nilai,
int4range(1, 10) @> int4range(2, 5) AS berisi_rentang,
int4range(2, 5) <@ int4range(1, 10) AS terkandung,
int4range(1, 10) && int4range(5, 15) AS overlap,
int4range(1, 5) << int4range(6, 9) AS strictly_kiri,
int4range(6, 9) >> int4range(1, 5) AS strictly_kanan,
int4range(1, 5) -|- int4range(5, 9) AS bersebelahan;
range-error.sql
Unduh berkas-- union dua rentang yang TERPISAH: hasil tidak mungkin menyatu
SELECT int4range(1, 3) + int4range(5, 7) AS union_terpisah;
tarif-periode.sql
Unduh berkas-- use case: tarif ongkir yang berlaku per periode waktu
CREATE TABLE tarif (
layanan text NOT NULL,
periode daterange NOT NULL,
harga_per_kg numeric(10, 2) NOT NULL,
PRIMARY KEY (layanan, periode)
);
INSERT INTO tarif (layanan, periode, harga_per_kg) VALUES
('reguler', daterange(DATE '2026-01-01', DATE '2026-04-01'), 12000),
('reguler', daterange(DATE '2026-04-01', DATE '2026-07-01'), 13500),
('ekspres', daterange(DATE '2026-01-01', DATE '2026-07-01'), 25000);
-- tarif mana saja yang berlaku pada 15 Maret 2026?
SELECT layanan, harga_per_kg, periode
FROM tarif
WHERE periode @> DATE '2026-03-15'
ORDER BY layanan;
-- dan pada 20 April 2026, saat kenaikan sudah berlaku?
SELECT layanan, harga_per_kg
FROM tarif
WHERE periode @> DATE '2026-04-20'
ORDER BY layanan;
-- range_merge: menggabungkan DUA rentang bertetangga menjadi satu rentang besar
SELECT range_merge(daterange(DATE '2026-01-01', DATE '2026-04-01'),
daterange(DATE '2026-04-01', DATE '2026-07-01')) AS semester;
-- multirange: kumpulan rentang yang tidak harus menyatu
CREATE TABLE jadwal_libur (
id integer PRIMARY KEY,
periode daterange NOT NULL
);
INSERT INTO jadwal_libur (id, periode) VALUES
(1, daterange(DATE '2026-01-01', DATE '2026-01-08')),
(2, daterange(DATE '2026-03-01', DATE '2026-03-05')),
(3, daterange(DATE '2026-01-05', DATE '2026-01-10'));
-- range_agg: melebur banyak rentang menjadi multirange (yang bertumpuk menyatu)
SELECT range_agg(periode) AS semua_libur
FROM jadwal_libur;
-- operator keanggotaan bekerja juga pada multirange
WITH libur AS (
SELECT range_agg(periode) AS periode_libur FROM jadwal_libur
)
SELECT DATE '2026-03-02' <@ periode_libur AS maret_libur,
DATE '2026-02-14' <@ periode_libur AS februari_libur
FROM libur;
-- literal dan constructor multirange
SELECT '{[1,3),[5,7)}'::int4multirange AS literal_multirange,
int4multirange(int4range(1, 3), int4range(5, 7)) AS konstruktor_multirange;