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:

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

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

OperatorMakna
r @> xrentang r memuat nilai x atau rentang x
r <@ xrentang r terkandung dalam x
r && sr dan s beririsan (overlap)
r << sr sepenuhnya di kiri s (strictly left)
r >> sr sepenuhnya di kanan s (strictly right)
r -|- sr 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:

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

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

sql range-error.sql
-- union dua rentang yang TERPISAH: hasil tidak mungkin menyatu
SELECT int4range(1, 3) + int4range(5, 7) AS union_terpisah;

Output yang Dihasilkan:

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

text
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 berkas
sql
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;

konstruksi-range.sql

Unduh berkas
sql
-- 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
sql
-- 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 berkas
sql
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;

range-error.sql

Unduh berkas
sql
-- union dua rentang yang TERPISAH: hasil tidak mungkin menyatu
SELECT int4range(1, 3) + int4range(5, 7) AS union_terpisah;

tarif-periode.sql

Unduh berkas
sql
-- 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;