Rantai -> dan path #> dari chapter sebelumnya mengambil satu nilai di satu jalur yang sudah diketahui. Namun pertanyaan dunia nyata sering berbentuk lain: elemen array mana saja yang jumlahnya dua atau lebih, produk mana yang harganya di atas seratus ribu, dokumen mana yang punya tag diskon di mana pun posisinya. Menulis kondisi seperti itu dengan operator per-langkah menuntut subquery dan perulangan yang canggung. PostgreSQL menjawabnya dengan bahasa query tersendiri untuk dokumen — JSONPath, bagian dari standar SQL/JSON — yang dioperasikan lewat keluarga fungsi jsonb_path_*. Kita akan memakainya atas katalog produk dan daftar pesanan Toko Maju Jaya: dari path sederhana, filter per elemen, sampai metode size() dan last.

Bahasa JSONPath dalam Satu Pandangan #

Sebuah ekspresi JSONPath selalu berangkat dari $ — akar dokumen — lalu menempuh kunci, indeks, dan penyaringan. Simbol @ di dalam penyaring merujuk pada nilai yang sedang diperiksa. Tabel berikut merangkum semua komponen yang dipakai di chapter ini:

KomponenArtiContoh
$akar dokumen'$'
$.kuncinilai kunci pada objek'$.merek'
$.a.bpath bertingkat'$.spesifikasi.berat'
$[*]semua elemen array'$[*].produk'
$.kunci[*]semua elemen array di kunci'$.tag[*]'
@nilai yang sedang diperiksa filter? (@.harga > 100000)
? (...)filter kondisi'$[*] ? (@.jumlah >= 2)'
exists (...)uji keberadaan path dalam filter? (exists (@.garansi))
lastindeks elemen terakhir'$.tag[last]'
size()jumlah elemen array'$.tag.size()'
like_regexpencocokan pola regex? (@.produk like_regex "^S")

Empat fungsi menjalankan ekspresi tersebut, masing-masing dengan bentuk hasil yang berbeda:

FungsiHasilKapan dipakai
jsonb_path_querynol atau lebih baris (set)mengambil semua nilai yang cocok
jsonb_path_query_firstsatu nilai jsonb atau NULLnilai pertama yang cocok
jsonb_path_query_arraysatu array jsonbsemua hasil dikumpulkan jadi array
jsonb_path_existsbooleansekadar menguji ada-tidaknya kecocokan
sql buat-tabel.sql
-- Katalog produk dan daftar pesanan untuk contoh JSONPath.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL
);

INSERT INTO produk (id, nama, info) VALUES
    (1, 'Sepatu Lari',
     '{"merek": "Elang", "harga": 750000, "stok": 25, "tag": ["olahraga", "diskon"], "spesifikasi": {"berat": 280, "bahan": "mesh", "dimensi": {"panjang": 31, "lebar": 11, "tinggi": 9}}}'),
    (2, 'Kemeja Batik',
     '{"merek": "Nusantara", "harga": 320000, "stok": 40, "tag": ["formal", "diskon"], "spesifikasi": {"berat": 220, "bahan": "katun"}}'),
    (3, 'Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}'),
    (4, 'Sendok Set 6',
     '{"merek": "Sinar Pagi", "harga": 65000, "stok": 120, "tag": ["dapur"], "spesifikasi": {"berat": 300, "bahan": "stainless"}}'),
    (5, 'Sarung Tangan Sepeda',
     '{"merek": "Elang", "harga": 85000, "stok": 60, "tag": ["olahraga"], "spesifikasi": {"berat": 90, "bahan": "kulit sintetis"}}'),
    (6, 'Topi Pantai',
     '{"merek": "Kasih", "harga": 45000, "stok": 200, "tag": ["outdoor", "diskon"], "spesifikasi": {"berat": 60, "bahan": "katun", "lipat": true}}'),
    (7, 'Termos 1L',
     '{"merek": "Sinar Pagi", "harga": 185000, "stok": 35, "tag": ["dapur", "bestseller"], "garansi": "12 bulan", "spesifikasi": {"berat": 540, "kapasitas": 1, "tahan_air": true}}'),
    (8, 'Kacamata Renang',
     '{"merek": "Elang", "harga": 95000, "stok": 75, "tag": ["olahraga", "bestseller"], "spesifikasi": {"berat": 120, "anti_uv": true}}');

-- Tiap pesanan membawa items: array objek produk yang dibeli.
CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    nama_pemesan text NOT NULL,
    items        jsonb NOT NULL
);

INSERT INTO pesanan (id, nama_pemesan, items) VALUES
    (1, 'Budi',
     '[{"produk": "Sepatu Lari", "jumlah": 1, "harga": 750000}, {"produk": "Kacamata Renang", "jumlah": 2, "harga": 95000}]'),
    (2, 'Siti',
     '[{"produk": "Kemeja Batik", "jumlah": 3, "harga": 320000}, {"produk": "Topi Pantai", "jumlah": 1, "harga": 45000}, {"produk": "Termos 1L", "jumlah": 2, "harga": 185000}]'),
    (3, 'Tono',
     '[{"produk": "Tas Ransel", "jumlah": 1, "harga": 450000}]');

SELECT id, nama_pemesan, items
FROM pesanan
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 8
CREATE TABLE
INSERT 0 3
 id | nama_pemesan |                                                                                   items                                                                                   
----+--------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  1 | Budi         | [{"harga": 750000, "jumlah": 1, "produk": "Sepatu Lari"}, {"harga": 95000, "jumlah": 2, "produk": "Kacamata Renang"}]
  2 | Siti         | [{"harga": 320000, "jumlah": 3, "produk": "Kemeja Batik"}, {"harga": 45000, "jumlah": 1, "produk": "Topi Pantai"}, {"harga": 185000, "jumlah": 2, "produk": "Termos 1L"}]
  3 | Tono         | [{"harga": 450000, "jumlah": 1, "produk": "Tas Ransel"}]
(3 rows)

Tabel pesanan menjadi arena utama kita karena kolom items-nya berisi array objek — struktur yang paling melelahkan bila dijangkau dengan rantai panah: jumlah elemennya berbeda per baris, sementara -> 0 selalu terpatok pada satu indeks.

Menyusun Path Dasar #

Path paling sederhana meniru apa yang sudah biasa kita lakukan dengan ->: mengambil kunci dari akar dokumen. Bedanya, seluruh jalur — berapa pun tingkatnya — ditulis sebagai satu ekspresi $.spesifikasi.berat, dan fungsi jsonb_path_query_first mengembalikan nilai pertama yang ditemukan.

sql path-dasar.sql
-- $.kunci: nilai tunggal dari akar dokumen.
SELECT nama, jsonb_path_query_first(info, '$.merek') AS merek
FROM produk
ORDER BY id;

-- Path bertingkat ditulis flat dalam satu ekspresi.
SELECT nama, jsonb_path_query_first(info, '$.spesifikasi.berat') AS berat
FROM produk
ORDER BY id;

-- jsonb_path_exists: menguji keberadaan path tanpa mengambil nilainya.
SELECT nama, jsonb_path_exists(info, '$.spesifikasi.dimensi') AS punya_dimensi
FROM produk
ORDER BY id;

-- Wildcard $[*]: menelusuri semua elemen array;
-- jsonb_path_query mengembalikan satu baris per hasil.
SELECT id, jsonb_path_query(items, '$[*].produk') AS produk_dibeli
FROM pesanan
ORDER BY id, 2;

-- jsonb_path_query_array: seluruh hasil wildcard dikumpulkan
-- kembali menjadi satu array jsonb.
SELECT id, jsonb_path_query_array(items, '$[*].produk') AS daftar_produk
FROM pesanan
ORDER BY id;

Output yang Dihasilkan:

text
         nama         |     merek     
----------------------+---------------
 Sepatu Lari          | "Elang"
 Kemeja Batik         | "Nusantara"
 Tas Ransel           | "Gunung Jaya"
 Sendok Set 6         | "Sinar Pagi"
 Sarung Tangan Sepeda | "Elang"
 Topi Pantai          | "Kasih"
 Termos 1L            | "Sinar Pagi"
 Kacamata Renang      | "Elang"
(8 rows)

         nama         | berat 
----------------------+-------
 Sepatu Lari          | 280
 Kemeja Batik         | 220
 Tas Ransel           | 850
 Sendok Set 6         | 300
 Sarung Tangan Sepeda | 90
 Topi Pantai          | 60
 Termos 1L            | 540
 Kacamata Renang      | 120
(8 rows)

         nama         | punya_dimensi 
----------------------+---------------
 Sepatu Lari          | t
 Kemeja Batik         | f
 Tas Ransel           | f
 Sendok Set 6         | f
 Sarung Tangan Sepeda | f
 Topi Pantai          | f
 Termos 1L            | f
 Kacamata Renang      | f
(8 rows)

 id |   produk_dibeli   
----+-------------------
  1 | "Kacamata Renang"
  1 | "Sepatu Lari"
  2 | "Kemeja Batik"
  2 | "Termos 1L"
  2 | "Topi Pantai"
  3 | "Tas Ransel"
(6 rows)

 id |                daftar_produk                 
----+----------------------------------------------
  1 | ["Sepatu Lari", "Kacamata Renang"]
  2 | ["Kemeja Batik", "Topi Pantai", "Termos 1L"]
  3 | ["Tas Ransel"]
(3 rows)

Empat pengamatan dari hasil di atas:

  1. $.merek dan $.spesifikasi.berat menghasilkan delapan nilai — sama seperti info -> 'merek' dan info -> 'spesifikasi' -> 'berat', tetapi ditulis dalam satu string path.
  2. jsonb_path_exists(info, '$.spesifikasi.dimensi') hanya bernilai t untuk Sepatu Lari: satu-satunya produk yang dokumennya memuat dimensi — menguji keberadaan path jauh lebih ringkas daripada membandingkan hasil -> dengan NULL.
  3. Wildcard '$[*].produk' menelusuri setiap elemen array items; karena jsonb_path_query mengembalikan satu baris per hasil, pesanan Budi menghasilkan dua baris dan pesanan Siti tiga baris — total enam baris dari tiga pesanan.
  4. jsonb_path_query_array mengumpulkan hasil wildcard yang sama kembali menjadi satu array — bentuk yang pas saat aplikasi butuh daftar lengkap per pesanan tanpa baris terpisah.

Menyaring dengan Filter ?() #

Inilah fitur yang tidak dimiliki operator panah: kondisi di dalam path. Filter ? (...) dievaluasi terhadap setiap kandidat — elemen array saat wildcard sedang berjalan, atau keseluruhan dokumen saat filter menempel langsung pada $.

sql filter.sql
-- Filter ?(): saring elemen array berdasarkan kondisi per elemen.
-- Simbol @ merujuk elemen yang sedang diperiksa.
SELECT id, jsonb_path_query(items, '$[*] ? (@.jumlah >= 2).produk') AS dibeli_banyak
FROM pesanan
ORDER BY id, 2;

-- Filter pada dokumen: hanya produk dengan harga di atas 100000.
SELECT nama, info ->> 'harga' AS harga
FROM produk
WHERE jsonb_path_exists(info, '$ ? (@.harga > 100000)')
ORDER BY id;

-- Wildcard di dalam kondisi: cukup SALAH SATU tag bernilai "diskon".
SELECT nama
FROM produk
WHERE jsonb_path_exists(info, '$ ? (@.tag[*] == "diskon")')
ORDER BY id;

-- exists: menguji keberadaan path di dalam filter.
SELECT nama, info ->> 'garansi' AS garansi
FROM produk
WHERE jsonb_path_exists(info, '$ ? (exists (@.garansi))')
ORDER BY id;

Output yang Dihasilkan:

text
 id |   dibeli_banyak   
----+-------------------
  1 | "Kacamata Renang"
  2 | "Kemeja Batik"
  2 | "Termos 1L"
(3 rows)

     nama     | harga  
--------------+--------
 Sepatu Lari  | 750000
 Kemeja Batik | 320000
 Tas Ransel   | 450000
 Termos 1L    | 185000
(4 rows)

     nama     
--------------
 Sepatu Lari
 Kemeja Batik
 Topi Pantai
(3 rows)

    nama    | garansi  
------------+----------
 Tas Ransel | 12 bulan
 Termos 1L  | 12 bulan
(2 rows)

Mari kita bedah keempat query tersebut:

  1. '$[*] ? (@.jumlah >= 2).produk' — wildcard menawarkan tiap elemen array ke filter; elemen dengan jumlah dua atau lebih lolos, lalu .produk mengambil nilainya. Hasilnya tiga produk dari dua pesanan; item berjumlah satu tidak pernah muncul.
  2. '$ ? (@.harga > 100000)' — filter menempel pada akar, jadi @ merujuk seluruh dokumen tiap baris tabel. Empat produk lolos ambang harga.
  3. '$ ? (@.tag[*] == "diskon")' — wildcard di dalam kondisi: perbandingan bernilai benar bila salah satu elemen tag sama dengan "diskon". Perilaku ini dijamin mode lax bawaan PostgreSQL, yang secara otomatis membuka array saat dibandingkan dengan skalar.
  4. '$ ? (exists (@.garansi))' — exists menguji keberadaan path; hasilnya dua produk bergaransi tanpa perlu membandingkan nilainya dengan apa pun.

Metode Bawaan: size, last, like_regex #

JSONPath membawa segelintir metode siap pakai. Metode size() menghitung jumlah elemen array; indeks last menunjuk elemen terakhir; dan like_regex mencocokkan nilai string dengan pola regular expression, lengkap dengan opsi flag.

sql metode.sql
-- size(): jumlah elemen array dalam dokumen.
SELECT id, jsonb_path_query_first(items, '$.size()') AS jumlah_item
FROM pesanan
ORDER BY id;

SELECT nama, jsonb_path_query_first(info, '$.tag.size()') AS jumlah_tag
FROM produk
WHERE id IN (1, 4)
ORDER BY id;

-- Indeks last: elemen terakhir tanpa perlu tahu panjang array.
SELECT nama, jsonb_path_query_first(info, '$.tag[last]') AS tag_terakhir
FROM produk
ORDER BY id;

-- like_regex: pencocokan pola terhadap nilai string.
-- Tanpa flag, pencocokan bersifat case-sensitive.
SELECT id, jsonb_path_query(items, '$[*] ? (@.produk like_regex "sepatu").produk') AS cocok_peka_huruf
FROM pesanan
ORDER BY id, 2;

-- flag "i" mengabaikan perbedaan huruf besar-kecil.
SELECT id, jsonb_path_query(items, '$[*] ? (@.produk like_regex "sepatu" flag "i").produk') AS cocok_abai_huruf
FROM pesanan
ORDER BY id, 2;

Output yang Dihasilkan:

text
 id | jumlah_item 
----+-------------
  1 | 2
  2 | 3
  3 | 1
(3 rows)

     nama     | jumlah_tag 
--------------+------------
 Sepatu Lari  | 2
 Sendok Set 6 | 1
(2 rows)

         nama         | tag_terakhir 
----------------------+--------------
 Sepatu Lari          | "diskon"
 Kemeja Batik         | "diskon"
 Tas Ransel           | "outdoor"
 Sendok Set 6         | "dapur"
 Sarung Tangan Sepeda | "olahraga"
 Topi Pantai          | "diskon"
 Termos 1L            | "bestseller"
 Kacamata Renang      | "bestseller"
(8 rows)

 id | cocok_peka_huruf 
----+------------------
(0 rows)

 id | cocok_abai_huruf 
----+------------------
  1 | "Sepatu Lari"
(1 row)

Query pertama menghitung item tiap pesanan lewat '$.size()' — angka 2, 3, 1 — dan query kedua menghitung tag produk lewat '$.tag.size()'. Indeks last mengembalikan tag terakhir tiap produk tanpa perlu tahu panjang arraynya; bandingkan hasil kolom ini dengan info -> 'tag' -> -1 dari chapter sebelumnya — keduanya setara. Dua query terakhir memperlihatkan sensitivitas huruf pada like_regex: pola "sepatu" huruf kecil tidak menemukan apa pun karena nama produk ditulis "Sepatu Lari", sedangkan flag "i" mengabaikan perbedaan huruf besar-kecil dan langsung menemukannya.

Tiga Cara Menempuh Dokumen #

Kita kini punya tiga teknik membaca nilai. Pilihan di antaranya adalah soal bentuk pertanyaan, bukan kekuatan:

TeknikContohHasilMedan terbaik
Rantai ->info -> 'spesifikasi' -> 'berat'satu nilaipath pendek yang selalu tetap
Path array #>info #> '{spesifikasi,berat}'satu nilaipath dalam/dinamis sebagai data
JSONPathjsonb_path_query(info, '$.spesifikasi.berat')nol atau lebih nilaifilter, wildcard, array multi-elemen

Ekspresi path dikirim sebagai string — jaga agar pola dari pengguna akhir tidak ditempel langsung dengan penyambungan teks: JSONPath punya sintaksnya sendiri, dan string yang tidak valid akan gagal saat parsing. Pada driver aplikasi, kirim path sebagai parameter terpisah sebagaimana Anda mengirim nilai SQL lainnya.

Dengan JSONPath, pertanyaan “dokumen mana yang memuat apa” terjawab dalam satu ekspresi. Chapter berikutnya melengkapi kotak perkakas dari arah yang berbeda: peta lengkap seluruh operator JSON dan JSONB — penampung @>, keanggotaan ?, penggabungan ||, sampai penghapusan kunci - dan #- — beserta jebakan masing-masing.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Katalog produk dan daftar pesanan untuk contoh JSONPath.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL
);

INSERT INTO produk (id, nama, info) VALUES
    (1, 'Sepatu Lari',
     '{"merek": "Elang", "harga": 750000, "stok": 25, "tag": ["olahraga", "diskon"], "spesifikasi": {"berat": 280, "bahan": "mesh", "dimensi": {"panjang": 31, "lebar": 11, "tinggi": 9}}}'),
    (2, 'Kemeja Batik',
     '{"merek": "Nusantara", "harga": 320000, "stok": 40, "tag": ["formal", "diskon"], "spesifikasi": {"berat": 220, "bahan": "katun"}}'),
    (3, 'Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}'),
    (4, 'Sendok Set 6',
     '{"merek": "Sinar Pagi", "harga": 65000, "stok": 120, "tag": ["dapur"], "spesifikasi": {"berat": 300, "bahan": "stainless"}}'),
    (5, 'Sarung Tangan Sepeda',
     '{"merek": "Elang", "harga": 85000, "stok": 60, "tag": ["olahraga"], "spesifikasi": {"berat": 90, "bahan": "kulit sintetis"}}'),
    (6, 'Topi Pantai',
     '{"merek": "Kasih", "harga": 45000, "stok": 200, "tag": ["outdoor", "diskon"], "spesifikasi": {"berat": 60, "bahan": "katun", "lipat": true}}'),
    (7, 'Termos 1L',
     '{"merek": "Sinar Pagi", "harga": 185000, "stok": 35, "tag": ["dapur", "bestseller"], "garansi": "12 bulan", "spesifikasi": {"berat": 540, "kapasitas": 1, "tahan_air": true}}'),
    (8, 'Kacamata Renang',
     '{"merek": "Elang", "harga": 95000, "stok": 75, "tag": ["olahraga", "bestseller"], "spesifikasi": {"berat": 120, "anti_uv": true}}');

-- Tiap pesanan membawa items: array objek produk yang dibeli.
CREATE TABLE pesanan (
    id           integer PRIMARY KEY,
    nama_pemesan text NOT NULL,
    items        jsonb NOT NULL
);

INSERT INTO pesanan (id, nama_pemesan, items) VALUES
    (1, 'Budi',
     '[{"produk": "Sepatu Lari", "jumlah": 1, "harga": 750000}, {"produk": "Kacamata Renang", "jumlah": 2, "harga": 95000}]'),
    (2, 'Siti',
     '[{"produk": "Kemeja Batik", "jumlah": 3, "harga": 320000}, {"produk": "Topi Pantai", "jumlah": 1, "harga": 45000}, {"produk": "Termos 1L", "jumlah": 2, "harga": 185000}]'),
    (3, 'Tono',
     '[{"produk": "Tas Ransel", "jumlah": 1, "harga": 450000}]');

SELECT id, nama_pemesan, items
FROM pesanan
ORDER BY id;

filter.sql

Unduh berkas
sql
-- Filter ?(): saring elemen array berdasarkan kondisi per elemen.
-- Simbol @ merujuk elemen yang sedang diperiksa.
SELECT id, jsonb_path_query(items, '$[*] ? (@.jumlah >= 2).produk') AS dibeli_banyak
FROM pesanan
ORDER BY id, 2;

-- Filter pada dokumen: hanya produk dengan harga di atas 100000.
SELECT nama, info ->> 'harga' AS harga
FROM produk
WHERE jsonb_path_exists(info, '$ ? (@.harga > 100000)')
ORDER BY id;

-- Wildcard di dalam kondisi: cukup SALAH SATU tag bernilai "diskon".
SELECT nama
FROM produk
WHERE jsonb_path_exists(info, '$ ? (@.tag[*] == "diskon")')
ORDER BY id;

-- exists: menguji keberadaan path di dalam filter.
SELECT nama, info ->> 'garansi' AS garansi
FROM produk
WHERE jsonb_path_exists(info, '$ ? (exists (@.garansi))')
ORDER BY id;

metode.sql

Unduh berkas
sql
-- size(): jumlah elemen array dalam dokumen.
SELECT id, jsonb_path_query_first(items, '$.size()') AS jumlah_item
FROM pesanan
ORDER BY id;

SELECT nama, jsonb_path_query_first(info, '$.tag.size()') AS jumlah_tag
FROM produk
WHERE id IN (1, 4)
ORDER BY id;

-- Indeks last: elemen terakhir tanpa perlu tahu panjang array.
SELECT nama, jsonb_path_query_first(info, '$.tag[last]') AS tag_terakhir
FROM produk
ORDER BY id;

-- like_regex: pencocokan pola terhadap nilai string.
-- Tanpa flag, pencocokan bersifat case-sensitive.
SELECT id, jsonb_path_query(items, '$[*] ? (@.produk like_regex "sepatu").produk') AS cocok_peka_huruf
FROM pesanan
ORDER BY id, 2;

-- flag "i" mengabaikan perbedaan huruf besar-kecil.
SELECT id, jsonb_path_query(items, '$[*] ? (@.produk like_regex "sepatu" flag "i").produk') AS cocok_abai_huruf
FROM pesanan
ORDER BY id, 2;

path-dasar.sql

Unduh berkas
sql
-- $.kunci: nilai tunggal dari akar dokumen.
SELECT nama, jsonb_path_query_first(info, '$.merek') AS merek
FROM produk
ORDER BY id;

-- Path bertingkat ditulis flat dalam satu ekspresi.
SELECT nama, jsonb_path_query_first(info, '$.spesifikasi.berat') AS berat
FROM produk
ORDER BY id;

-- jsonb_path_exists: menguji keberadaan path tanpa mengambil nilainya.
SELECT nama, jsonb_path_exists(info, '$.spesifikasi.dimensi') AS punya_dimensi
FROM produk
ORDER BY id;

-- Wildcard $[*]: menelusuri semua elemen array;
-- jsonb_path_query mengembalikan satu baris per hasil.
SELECT id, jsonb_path_query(items, '$[*].produk') AS produk_dibeli
FROM pesanan
ORDER BY id, 2;

-- jsonb_path_query_array: seluruh hasil wildcard dikumpulkan
-- kembali menjadi satu array jsonb.
SELECT id, jsonb_path_query_array(items, '$[*].produk') AS daftar_produk
FROM pesanan
ORDER BY id;