Dokumen sudah tersimpan rapi di kolom jsonb — sekarang giliran pertanyaan yang paling sering diajukan aplikasi: bagaimana cara mengambil satu nilai dari dalamnya? Nilai itu bisa bersembunyi dua tingkat bersarang di bawah spesifikasi, tersebar sebagai elemen array tag, atau sama sekali tidak ada di sebagian baris. PostgreSQL menyediakan satu keluarga operator untuk semua kasus itu: dua panah untuk akses per langkah, dua operator path untuk menempuh jalur sekaligus. Kita membedah keduanya di atas katalog produk Toko Maju Jaya, menutup dengan keterampilan yang tak terhindarkan: hasil ->> selalu text, jadi angka harus dicast sebelum bisa dihitung.
Dua Panah: -> untuk jsonb, -» untuk text #
Perbedaan kedua operator ini murni soal tipe hasil. Operator -> mengembalikan nilai bertipe jsonb — tanda kutip string ikut terbawa, dan hasilnya bisa dirantai lagi ke operator berikutnya. Operator ->> mengembalikan text murni — siap ditampilkan, digabung, atau dibandingkan sebagai string, tetapi tidak bisa diakses lagi per kunci.
| Ekspresi | Tipe hasil | Contoh hasil |
|---|---|---|
info -> 'merek' | jsonb | "Elang" (kutip ikut) |
info ->> 'merek' | text | Elang (tanpa kutip) |
info -> 'tag' -> 0 | jsonb | "olahraga" |
info ->> 'harga' | text | 750000 (angka sebagai teks) |
info -> 'spesifikasi' | jsonb | objek bersarang utuh |
-- Katalog produk untuk seluruh contoh pembacaan nilai di chapter ini.
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}}');Output yang Dihasilkan:
CREATE TABLE
INSERT 0 8-- Bukti tipe: -> mengembalikan jsonb, ->> mengembalikan text.
SELECT pg_typeof(info -> 'merek') AS tipe_panah_tunggal,
pg_typeof(info ->> 'merek') AS tipe_panah_ganda
FROM produk
WHERE id = 1;
-- Perbedaan tampilan: kutip string hanya ikut pada hasil ->.
SELECT nama,
info -> 'merek' AS merek_jsonb,
info ->> 'merek' AS merek_teks
FROM produk
ORDER BY id;
-- Path bertingkat: objek di dalam objek, dirantai bertahap.
SELECT nama,
info -> 'spesifikasi' -> 'berat' AS berat_jsonb,
info -> 'spesifikasi' ->> 'berat' AS berat_teks
FROM produk
ORDER BY id;
-- Elemen array diakses dengan indeks mulai dari 0;
-- indeks negatif berhitung dari belakang.
SELECT nama,
info -> 'tag' -> 0 AS tag_pertama,
info -> 'tag' ->> 0 AS tag_pertama_teks,
info -> 'tag' -> -1 AS tag_terakhir
FROM produk
ORDER BY id;
-- ->> pada nilai objek: isinya diserialisasi menjadi teks, bukan error.
SELECT info ->> 'spesifikasi' AS spek_sebagai_teks
FROM produk
WHERE id = 2;
-- Kunci yang tidak ada menghasilkan NULL, bukan error.
SELECT nama,
info ->> 'garansi' AS garansi
FROM produk
ORDER BY id;
-- jsonb_typeof membedakan jenis nilai di dalam dokumen.
SELECT jsonb_typeof(info -> 'harga') AS tipe_harga,
jsonb_typeof(info -> 'tag') AS tipe_tag,
jsonb_typeof(info -> 'spesifikasi') AS tipe_spek
FROM produk
WHERE id = 1;Output yang Dihasilkan:
tipe_panah_tunggal | tipe_panah_ganda
--------------------+------------------
jsonb | text
(1 row)
nama | merek_jsonb | merek_teks
----------------------+---------------+-------------
Sepatu Lari | "Elang" | Elang
Kemeja Batik | "Nusantara" | Nusantara
Tas Ransel | "Gunung Jaya" | Gunung Jaya
Sendok Set 6 | "Sinar Pagi" | Sinar Pagi
Sarung Tangan Sepeda | "Elang" | Elang
Topi Pantai | "Kasih" | Kasih
Termos 1L | "Sinar Pagi" | Sinar Pagi
Kacamata Renang | "Elang" | Elang
(8 rows)
nama | berat_jsonb | berat_teks
----------------------+-------------+------------
Sepatu Lari | 280 | 280
Kemeja Batik | 220 | 220
Tas Ransel | 850 | 850
Sendok Set 6 | 300 | 300
Sarung Tangan Sepeda | 90 | 90
Topi Pantai | 60 | 60
Termos 1L | 540 | 540
Kacamata Renang | 120 | 120
(8 rows)
nama | tag_pertama | tag_pertama_teks | tag_terakhir
----------------------+-------------+------------------+--------------
Sepatu Lari | "olahraga" | olahraga | "diskon"
Kemeja Batik | "formal" | formal | "diskon"
Tas Ransel | "olahraga" | olahraga | "outdoor"
Sendok Set 6 | "dapur" | dapur | "dapur"
Sarung Tangan Sepeda | "olahraga" | olahraga | "olahraga"
Topi Pantai | "outdoor" | outdoor | "diskon"
Termos 1L | "dapur" | dapur | "bestseller"
Kacamata Renang | "olahraga" | olahraga | "bestseller"
(8 rows)
spek_sebagai_teks
----------------------------------
{"bahan": "katun", "berat": 220}
(1 row)
nama | garansi
----------------------+----------
Sepatu Lari |
Kemeja Batik |
Tas Ransel | 12 bulan
Sendok Set 6 |
Sarung Tangan Sepeda |
Topi Pantai |
Termos 1L | 12 bulan
Kacamata Renang |
(8 rows)
tipe_harga | tipe_tag | tipe_spek
------------+----------+-----------
number | array | object
(1 row)Query pg_typeof di awal file membuktikan tabel di atas secara langsung: jsonb dan text. Query kedua memperlihatkan kolom merek_jsonb yang selalu bertanda kutip lawan merek_teks yang bersih. Perhatikan satu kejutan kecil pada angka: berat_teks tampil 280 tanpa kutip — angka JSON memang tidak dibungkus kutip sejak awal, jadi perbedaan -> dan ->> untuk angka tidak terlihat di layar meskipun tipenya tetap berbeda.
Path Bertingkat dan Elemen Array #
Nilai yang bersarang dijangkau dengan merantai operator: info -> 'spesifikasi' -> 'berat' masuk dulu ke objek spesifikasi, lalu mengambil kunci berat di dalamnya. Rantai bisa sepanjang apa pun — dimensi di produk 1 bahkan berada tiga tingkat di bawah akar. Elemen array memakai indeks angka alih-alih nama kunci, dihitung mulai dari nol; indeks negatif berhitung dari belakang, jadi info -> 'tag' -> -1 mengambil tag terakhir tanpa perlu tahu panjang arraynya. Query jsonb_typeof di akhir file menegaskan jenis tiap nilai: harga adalah number, tag adalah array, spesifikasi adalah object — pengetahuan yang menentukan operator apa yang sah untuk langkah berikutnya.
Dua perilaku dari file yang sama layak digarisbawahi. Pertama, info ->> 'spesifikasi' tidak error meski spesifikasi adalah objek: ->> menyerialisasikan seluruh isinya menjadi satu teks JSON. Kedua, kunci yang tidak ada — garansi hanya hadir pada Tas Ransel dan Termos 1L — menghasilkan NULL dengan tenang, bukan error. Perilaku NULL ini sangat memudahkan query lintas dokumen bervariasi: satu query yang sama melayani delapan struktur berbeda tanpa CASE percabangan.
Path sebagai Array: #> dan #» #
Rantai -> tiga tingkat berbunyi panjang dan harus ditulis persis sama di setiap query. Operator #> menawarkan bentuk ringkas: seluruh jalur ditulis sebagai satu array teks — info #> '{spesifikasi,dimensi,panjang}' — dan #>> adalah pasangannya yang mengembalikan text. Elemen array dalam path ditulis sebagai angka: '{tag,0}' berarti kunci tag lalu indeks ke-0.
-- #> dan #>> menempuh path yang ditulis sebagai array teks.
-- Hasil #> berupa jsonb; hasil #>> berupa text.
SELECT nama,
info #> '{spesifikasi,dimensi,panjang}' AS panjang_jsonb,
info #>> '{spesifikasi,dimensi,panjang}' AS panjang_teks
FROM produk
ORDER BY id;
-- Path boleh memuat indeks array setelah nama kunci.
SELECT nama,
info #>> '{tag,0}' AS tag_pertama,
info #>> '{tag,1}' AS tag_kedua
FROM produk
ORDER BY id;
-- Path yang putus di tengah menghasilkan NULL, bukan error.
SELECT nama,
info #> '{spesifikasi,kapasitas}' AS kapasitas
FROM produk
WHERE id IN (1, 3, 7)
ORDER BY id;
-- Rantai -> dan path #> menghasilkan nilai yang sama persis.
SELECT '{"spesifikasi": {"berat": 280}}'::jsonb -> 'spesifikasi' -> 'berat' AS lewat_rantai,
'{"spesifikasi": {"berat": 280}}'::jsonb #> '{spesifikasi,berat}' AS lewat_path;Output yang Dihasilkan:
nama | panjang_jsonb | panjang_teks
----------------------+---------------+--------------
Sepatu Lari | 31 | 31
Kemeja Batik | |
Tas Ransel | |
Sendok Set 6 | |
Sarung Tangan Sepeda | |
Topi Pantai | |
Termos 1L | |
Kacamata Renang | |
(8 rows)
nama | tag_pertama | tag_kedua
----------------------+-------------+------------
Sepatu Lari | olahraga | diskon
Kemeja Batik | formal | diskon
Tas Ransel | olahraga | outdoor
Sendok Set 6 | dapur |
Sarung Tangan Sepeda | olahraga |
Topi Pantai | outdoor | diskon
Termos 1L | dapur | bestseller
Kacamata Renang | olahraga | bestseller
(8 rows)
nama | kapasitas
-------------+-----------
Sepatu Lari |
Tas Ransel | 30
Termos 1L | 1
(3 rows)
lewat_rantai | lewat_path
--------------+------------
280 | 280
(1 row)Hasil pertama memperlihatkan kekuatan sekaligus kelembutannya: hanya Sepatu Lari yang memiliki dimensi.panjang, tujuh baris lain menghasilkan NULL — path yang putus di tengah jalan tidak pernah melempar error. Query kedua menambahkan bukti dari sisi array: Sendok Set 6 dan Sarung Tangan Sepeda hanya punya satu tag, sehingga '{tag,1}' menghasilkan NULL untuk keduanya. Query terakhir menegaskan bahwa rantai -> dan path #> benar-benar setara — keduanya menghasilkan 280 dari dokumen yang sama.
Cast Hasil ke Tipe SQL #
Angka di dokumen JSON berpindah ke SQL sebagai text begitu melewati ->>. Untuk operasi aritmetika, agregasi, atau perbandingan numerik, hasilnya harus dicast dulu ke tipe SQL yang tepat — ::integer untuk stok, ::numeric untuk harga.
-- Hasil ->> selalu text, jadi operasi numerik menuntut cast eksplisit.
SELECT nama,
(info ->> 'stok')::integer AS stok,
(info ->> 'stok')::integer * 2 AS stok_ganda
FROM produk
ORDER BY id;
SELECT nama,
(info ->> 'harga')::numeric AS harga,
round((info ->> 'harga')::numeric * 0.9) AS harga_diskon
FROM produk
ORDER BY id;Output yang Dihasilkan:
nama | stok | stok_ganda
----------------------+------+------------
Sepatu Lari | 25 | 50
Kemeja Batik | 40 | 80
Tas Ransel | 15 | 30
Sendok Set 6 | 120 | 240
Sarung Tangan Sepeda | 60 | 120
Topi Pantai | 200 | 400
Termos 1L | 35 | 70
Kacamata Renang | 75 | 150
(8 rows)
nama | harga | harga_diskon
----------------------+--------+--------------
Sepatu Lari | 750000 | 675000
Kemeja Batik | 320000 | 288000
Tas Ransel | 450000 | 405000
Sendok Set 6 | 65000 | 58500
Sarung Tangan Sepeda | 85000 | 76500
Topi Pantai | 45000 | 40500
Termos 1L | 185000 | 166500
Kacamata Renang | 95000 | 85500
(8 rows)Kedua query memperlihatkan polanya: (info ->> 'stok')::integer menghasilkan angka sungguhan yang bisa dikalikan dua, dan (info ->> 'harga')::numeric mengikuti perhitungan diskon 10% dengan round(). Tanpa cast, kedua ekspresi matematika itu akan ditolak parser karena operator * tidak terdefinisi untuk text. Cast juga punya sisi tegasnya — nilai teks yang bukan angka membuat cast gagal keras:
-- Teks yang bukan angka tidak bisa dicast ke integer.
SELECT nama, (info ->> 'merek')::integer AS merek_salah_tipe
FROM produk
WHERE id = 1;Output yang Dihasilkan:
psql:cast-gagal.sql:4: ERROR: invalid input syntax for type integer: "Elang"Error invalid input syntax for type integer: "Elang" muncul karena nilai merek memang teks nama. Kegagalan seperti ini biasanya menandakan dua kemungkinan: tipe nilai di dokumen berubah tanpa koordinasi (aplikasi lama menulis angka, aplikasi baru menulis teks), atau query salah memilih kunci. CHECK constraint dari chapter Menyimpan Data JSON adalah pagar pertama untuk mencegah kategori pertama.
Rantai panah dan path array kini menjadi keterampilan yang siap pakai. Namun keduanya tetap mengambil satu nilai pada satu jalur — belum ada cara menyatakan “semua elemen array yang harganya di atas seratus ribu” dalam satu ekspresi ringkas. Untuk itu PostgreSQL menyediakan bahasa query tersendiri untuk dokumen: JSONPath, materi chapter berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- Katalog produk untuk seluruh contoh pembacaan nilai di chapter ini.
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}}');
cast-gagal.sql
Unduh berkas-- Teks yang bukan angka tidak bisa dicast ke integer.
SELECT nama, (info ->> 'merek')::integer AS merek_salah_tipe
FROM produk
WHERE id = 1;
cast-tipe.sql
Unduh berkas-- Hasil ->> selalu text, jadi operasi numerik menuntut cast eksplisit.
SELECT nama,
(info ->> 'stok')::integer AS stok,
(info ->> 'stok')::integer * 2 AS stok_ganda
FROM produk
ORDER BY id;
SELECT nama,
(info ->> 'harga')::numeric AS harga,
round((info ->> 'harga')::numeric * 0.9) AS harga_diskon
FROM produk
ORDER BY id;
operator-panah.sql
Unduh berkas-- Bukti tipe: -> mengembalikan jsonb, ->> mengembalikan text.
SELECT pg_typeof(info -> 'merek') AS tipe_panah_tunggal,
pg_typeof(info ->> 'merek') AS tipe_panah_ganda
FROM produk
WHERE id = 1;
-- Perbedaan tampilan: kutip string hanya ikut pada hasil ->.
SELECT nama,
info -> 'merek' AS merek_jsonb,
info ->> 'merek' AS merek_teks
FROM produk
ORDER BY id;
-- Path bertingkat: objek di dalam objek, dirantai bertahap.
SELECT nama,
info -> 'spesifikasi' -> 'berat' AS berat_jsonb,
info -> 'spesifikasi' ->> 'berat' AS berat_teks
FROM produk
ORDER BY id;
-- Elemen array diakses dengan indeks mulai dari 0;
-- indeks negatif berhitung dari belakang.
SELECT nama,
info -> 'tag' -> 0 AS tag_pertama,
info -> 'tag' ->> 0 AS tag_pertama_teks,
info -> 'tag' -> -1 AS tag_terakhir
FROM produk
ORDER BY id;
-- ->> pada nilai objek: isinya diserialisasi menjadi teks, bukan error.
SELECT info ->> 'spesifikasi' AS spek_sebagai_teks
FROM produk
WHERE id = 2;
-- Kunci yang tidak ada menghasilkan NULL, bukan error.
SELECT nama,
info ->> 'garansi' AS garansi
FROM produk
ORDER BY id;
-- jsonb_typeof membedakan jenis nilai di dalam dokumen.
SELECT jsonb_typeof(info -> 'harga') AS tipe_harga,
jsonb_typeof(info -> 'tag') AS tipe_tag,
jsonb_typeof(info -> 'spesifikasi') AS tipe_spek
FROM produk
WHERE id = 1;
path-operator.sql
Unduh berkas-- #> dan #>> menempuh path yang ditulis sebagai array teks.
-- Hasil #> berupa jsonb; hasil #>> berupa text.
SELECT nama,
info #> '{spesifikasi,dimensi,panjang}' AS panjang_jsonb,
info #>> '{spesifikasi,dimensi,panjang}' AS panjang_teks
FROM produk
ORDER BY id;
-- Path boleh memuat indeks array setelah nama kunci.
SELECT nama,
info #>> '{tag,0}' AS tag_pertama,
info #>> '{tag,1}' AS tag_kedua
FROM produk
ORDER BY id;
-- Path yang putus di tengah menghasilkan NULL, bukan error.
SELECT nama,
info #> '{spesifikasi,kapasitas}' AS kapasitas
FROM produk
WHERE id IN (1, 3, 7)
ORDER BY id;
-- Rantai -> dan path #> menghasilkan nilai yang sama persis.
SELECT '{"spesifikasi": {"berat": 280}}'::jsonb -> 'spesifikasi' -> 'berat' AS lewat_rantai,
'{"spesifikasi": {"berat": 280}}'::jsonb #> '{spesifikasi,berat}' AS lewat_path;