Kita sudah memakai operator ->, ->>, dan #> untuk membaca nilai, serta @> sekilas saat menyaring baris. Namun keluarga operator JSON PostgreSQL jauh lebih lengkap: ada operator keanggotaan untuk menguji kunci, operator penampung dua arah, operator penggabungan dokumen, sampai operator penghapusan kunci dan path. Chapter ini menyusun semuanya menjadi satu peta di atas katalog produk Toko Maju Jaya, lalu menguji tiap kelompok dengan contoh yang sengaja memuat jebakannya — karena hampir setiap operator JSON punya satu perilaku tak terduga yang menghantam pemula tepat di produksi.

Peta Lengkap Operator #

Seluruh operator di tabel berikut bekerja pada tipe jsonb (operator akses dasar juga tersedia untuk json, seperti dibahas di chapter JSON vs JSONB):

OperatorTugasHasil
->ambil nilai kunci/indeksjsonb
->>ambil nilai kunci/indeks sebagai tekstext
#>ambil nilai lewat path text[]jsonb
#>>ambil nilai lewat path sebagai tekstext
@>sisi kiri memuat seluruh struktur sisi kananboolean
<@sisi kiri terkandung dalam sisi kananboolean
?string hadir sebagai kunci (objek) atau elemen (array)boolean
?&semua string pada array hadirboolean
`?`salah satu string pada array hadir
||gabungkan dua dokumenjsonb
-hapus kunci (objek) atau indeks (array)jsonb
#-hapus nilai di ujung path text[]jsonb
sql buat-tabel.sql
-- Katalog produk untuk peta operator JSON dan JSONB.
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:

text
CREATE TABLE
INSERT 0 8

Penampung @> dan <@ #

Operator @> menjawab pertanyaan “apakah dokumen ini memuat struktur itu?” — dan jawabannya ditentukan arah operator. Semantiknya juga bukan kesamaan persis melainkan penampungan parsial: dokumen kiri boleh membawa kunci tambahan selama seluruh pasangan sisi kanan hadir dengan nilai yang sama.

sql containment.sql
-- Arah menentukan: @> menguji "sisi kiri memuat sisi kanan".
SELECT '{"merek": "Elang", "harga": 750000}'::jsonb @> '{"merek": "Elang"}' AS kiri_memuat_kanan;

-- Dibalik arahnya, dokumen kecil tidak memuat dokumen besar.
SELECT '{"merek": "Elang"}'::jsonb @> '{"merek": "Elang", "harga": 750000}' AS kanan_memuat_kiri;

-- <@ adalah @> dengan arah dibalik: kiri terkandung dalam kanan.
SELECT '{"merek": "Elang"}'::jsonb <@ '{"merek": "Elang", "harga": 750000}' AS kiri_terkandung;

-- Angka dibandingkan secara NUMERIK: 25 dan 25.0 dianggap sama.
SELECT '{"stok": 25}'::jsonb @> '{"stok": 25.0}'::jsonb AS angka_sama_numerik;

-- Teks "25" dan angka 25 adalah dua nilai JSON yang berbeda.
SELECT '{"stok": 25}'::jsonb @> '{"stok": "25"}'::jsonb AS teks_vs_angka;

-- Penampung objek bersarang bersifat PARSIAL:
-- cukup pasangan yang disebut hadir, kunci lain boleh ikut.
SELECT info @> '{"spesifikasi": {"berat": 280}}' AS spek_cocok
FROM produk
WHERE id = 1;

-- Bentuk pemakaian paling umum: penyaringan baris di WHERE.
SELECT nama
FROM produk
WHERE info @> '{"tag": ["olahraga"]}'
ORDER BY id;

SELECT nama
FROM produk
WHERE info @> '{"spesifikasi": {"tahan_air": true}}'
ORDER BY id;

Output yang Dihasilkan:

text
 kiri_memuat_kanan 
-------------------
 t
(1 row)

 kanan_memuat_kiri 
-------------------
 f
(1 row)

 kiri_terkandung 
-----------------
 t
(1 row)

 angka_sama_numerik 
--------------------
 t
(1 row)

 teks_vs_angka 
---------------
 f
(1 row)

 spek_cocok 
------------
 t
(1 row)

         nama         
----------------------
 Sepatu Lari
 Tas Ransel
 Sarung Tangan Sepeda
 Kacamata Renang
(4 rows)

    nama    
------------
 Tas Ransel
 Termos 1L
(2 rows)

Empat query pertama memetakan perilakunya. {"merek": "Elang", "harga": 750000} memuat {"merek": "Elang"} — benar; dibalik, dokumen kecil tidak memuat dokumen yang lebih kaya — salah; dan <@ hanyalah @> yang diputar arah. Query keempat dan kelima memperlihatkan aturan tipe nilainya: angka dibandingkan secara numerik sehingga 25 dan 25.0 dianggap sama, tetapi teks "25" dan angka 25 adalah dua nilai JSON yang berbeda dan tidak saling memuat.

Dua query terakhir adalah pemakaian nyatanya. info @> '{"tag": ["olahraga"]}' mengembalikan produk yang array tag-nya memuat elemen "olahraga" — operator tidak menuntut arraynya identik, cukup elemennya hadir. Penampungan juga menembus struktur bersarang secara parsial: {"spesifikasi": {"berat": 280}} dianggap terkandung meskipun spesifikasi aslinya jauh lebih kaya — cukup pasangan yang disebut hadir. Bentuk WHERE inilah yang diakselerasi index GIN, topik chapter Index pada JSONB .

Keanggotaan ?, ?&, dan ?| #

Keluarga ? menguji keberadaan string — sebagai kunci pada objek, atau sebagai elemen pada array. Batasannya satu dan tegas: pemeriksaan hanya terjadi pada level teratas nilai yang diuji.

sql keanggotaan.sql
-- ? menguji keberadaan kunci pada LEVEL TERATAS dokumen.
SELECT nama
FROM produk
WHERE info ? 'garansi'
ORDER BY id;

-- 'berat' hanya hidup di dalam objek spesifikasi, bukan level teratas;
-- hasilnya nol baris -- inilah jebakan klasik operator ?.
SELECT count(*) AS punya_berat_level_atas
FROM produk
WHERE info ? 'berat';

-- Turun dulu satu tingkat, baru uji keanggotaan pada objek itu.
SELECT count(*) AS punya_berat_di_spek
FROM produk
WHERE info -> 'spesifikasi' ? 'berat';

-- Pada array, ? menguji KEANGGOTAN ELEMEN, bukan kunci.
SELECT nama
FROM produk
WHERE info -> 'tag' ? 'diskon'
ORDER BY id;

-- ?& menuntut SEMUA kunci hadir.
SELECT nama
FROM produk
WHERE info ?& ARRAY['garansi', 'tag']
ORDER BY id;

-- ?| cukup SALAH SATU kunci hadir.
SELECT nama
FROM produk
WHERE info ?| ARRAY['garansi', 'stok']
ORDER BY id;

Output yang Dihasilkan:

text
    nama    
------------
 Tas Ransel
 Termos 1L
(2 rows)

 punya_berat_level_atas 
------------------------
                      0
(1 row)

 punya_berat_di_spek 
---------------------
                   8
(1 row)

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

    nama    
------------
 Tas Ransel
 Termos 1L
(2 rows)

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

info ? 'garansi' menemukan dua produk bergaransi. Namun info ? 'berat' menemukan nol baris — padahal semua delapan produk punya berat di dalam spesifikasi. Itu bukan bug: ? tidak menuruni struktur; ia hanya melihat kunci level atas. Solusinya dua langkah: turun dulu dengan -> lalu uji keanggotaan pada objek tersebut — info -> 'spesifikasi' ? 'berat' menemukan kedelapan produk. Pada array, ? berpindah peran menjadi uji keanggotaan elemen: info -> 'tag' ? 'diskon' menemukan tiga produk yang tag-nya memuat "diskon".

Dua operator terakhir menerima array string SQL sebagai operand kanan. ?& (dan) menuntut semua kunci hadir — hanya dua produk yang punya garansi sekaligus tag. ?| (atau) cukup salah satu — karena semua produk punya stok, hasilnya seluruh tabel.

Penggabungan || #

Operator || menggabungkan dua nilai jsonb. Untuk dua objek, hasilnya adalah union kunci-kuncinya; untuk array dan nilai, nilai kanan menempel di ujung array kiri.

-- || menggabungkan dua objek jsonb;
-- kunci yang bentrok: nilai sisi KANAN yang menang.
SELECT '{"a": 1, "b": 2}'::jsonb || '{"b": 99, "c": 3}' AS kanan_menang;

-- Penggabungan bersifat DANGKAL: objek bersarang diganti utuh,
-- isi lamanya tidak dilebur dengan isi yang baru.
SELECT '{"spesifikasi": {"berat": 280, "bahan": "mesh"}}'::jsonb
       || '{"spesifikasi": {"bahan": "kulit"}}' AS merge_dangkal;

-- Perbarui satu kunci pada dokumen produk.
SELECT info || '{"stok": 10}' AS stok_diperbarui
FROM produk
WHERE id = 1;

-- Array || nilai: elemen baru ditambahkan di ujung array.
SELECT info -> 'tag' || '"murah"' AS tag_diperluas
FROM produk
WHERE id = 1;

Output yang Dihasilkan:

text
       kanan_menang        
---------------------------
 {"a": 1, "b": 99, "c": 3}
(1 row)

            merge_dangkal            
-------------------------------------
 {"spesifikasi": {"bahan": "kulit"}}
(1 row)

                                                                                   stok_diperbarui                                                                                    
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"tag": ["olahraga", "diskon"], "stok": 10, "harga": 750000, "merek": "Elang", "spesifikasi": {"bahan": "mesh", "berat": 280, "dimensi": {"lebar": 11, "tinggi": 9, "panjang": 31}}}
(1 row)

          tag_diperluas          
---------------------------------
 ["olahraga", "diskon", "murah"]
(1 row)

Query pertama menunjukkan aturan bentrok: kunci b hadir di kedua sisi, dan nilai sisi kanan (99) yang menang. Query kedua memperlihatkan sifat yang jarang disadari — penggabungan || bersifat dangkal (shallow): objek bersarang diganti utuh, bukan dilebur. spesifikasi yang seharusnya bisa mempertahankan berat: 280 lenyap diganti objek baru yang hanya berisi bahan. Dua query terakhir memperlihatkan pemakaian sehari-hari: memperbarui satu kunci (info || '{"stok": 10}') dan menambah elemen array (info -> 'tag' || '"murah"').

Penghapusan - dan #- #

Dua operator penutup peta justru paling sering dicari: cara membuang bagian dokumen. Operator - menerima kunci (untuk objek) atau indeks (untuk array); operator #- menerima path text[] dan membuang nilai di ujungnya.

sql penghapusan.sql
-- - 'kunci': buang pasangan kunci-nilai dari objek.
SELECT info - 'tag' AS tanpa_tag
FROM produk
WHERE id = 1;

-- - indeks: buang elemen array pada posisi tersebut.
-- Tanda kurung wajib: tanpa itu, ekspresi 'tag' - 0 yang terparse lebih dulu.
SELECT (info -> 'tag') - 0 AS tag_tanpa_pertama
FROM produk
WHERE id = 1;

-- #- path text[]: buang nilai yang berdiri di ujung path.
SELECT info #- '{spesifikasi,berat}' AS tanpa_berat
FROM produk
WHERE id = 1;

-- Path boleh memuat indeks array setelah nama kunci.
SELECT info #- '{tag,0}' AS tag_tanpa_pertama
FROM produk
WHERE id = 1;

-- Penghapusan berlapis: path masuk dua tingkat ke dalam objek.
SELECT '{"a": {"b": {"c": 1}}}'::jsonb #- '{a,b}' AS jalur_dihapus;

Output yang Dihasilkan:

text
                                                                       tanpa_tag                                                                       
-------------------------------------------------------------------------------------------------------------------------------------------------------
 {"stok": 25, "harga": 750000, "merek": "Elang", "spesifikasi": {"bahan": "mesh", "berat": 280, "dimensi": {"lebar": 11, "tinggi": 9, "panjang": 31}}}
(1 row)

 tag_tanpa_pertama 
-------------------
 ["diskon"]
(1 row)

                                                                              tanpa_berat                                                                               
------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"tag": ["olahraga", "diskon"], "stok": 25, "harga": 750000, "merek": "Elang", "spesifikasi": {"bahan": "mesh", "dimensi": {"lebar": 11, "tinggi": 9, "panjang": 31}}}
(1 row)

                                                                            tag_tanpa_pertama                                                                             
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 {"tag": ["diskon"], "stok": 25, "harga": 750000, "merek": "Elang", "spesifikasi": {"bahan": "mesh", "berat": 280, "dimensi": {"lebar": 11, "tinggi": 9, "panjang": 31}}}
(1 row)

 jalur_dihapus 
---------------
 {"a": {}}
(1 row)

Lima pengamatan dari hasil di atas:

  1. info - 'tag' membuang seluruh pasangan tag beserta array-nya dari objek.
  2. (info -> 'tag') - 0 membuang elemen ke-0 dari array — hasil ["diskon"]. Tanda kurung di sini wajib: operator +/- terikat lebih erat daripada ->, jadi tanpa kurung, parser membaca 'tag' - 0 lebih dulu dan gagal dengan error invalid input syntax for type integer: "tag".
  3. info #- '{spesifikasi,berat}' menghapus berat di dalam objek bersarang, menyisakan spesifikasi yang lebih muda.
  4. info #- '{tag,0}' memadukan kunci dan indeks dalam satu path.
  5. '{"a": {"b": {"c": 1}}}'::jsonb #- '{a,b}' membuang b dua tingkat di bawah akar, menyisakan {"a": {}}.

Semua operator pengubahan ini mengembalikan dokumen baru — kolom aslinya tidak berubah sampai Anda menjalankan UPDATE. Teknik mengubah dokumen secara penuh (memasukkan nilai di tengah array, mengganti nilai pada path, sampai pola UPDATE yang aman untuk concurrency) dibahas khusus di chapter Mengubah Data JSON.

Dengan peta operator lengkap di tangan, membaca dan menyaring dokumen jsonb menjadi soal memilih alat yang tepat: -> untuk satu nilai, @> untuk penyaringan berbasis index, ? untuk keanggotaan level atas, || dan - untuk merakit-ulang dokumen. Pengetahuan ini adalah fondasi bagi topik lanjutan section ini: mengubah dokumen secara terarah dengan jsonb_set, lalu merangkai baris tabel menjadi dokumen dengan json_agg.

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Katalog produk untuk peta operator JSON dan JSONB.
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}}');

containment.sql

Unduh berkas
sql
-- Arah menentukan: @> menguji "sisi kiri memuat sisi kanan".
SELECT '{"merek": "Elang", "harga": 750000}'::jsonb @> '{"merek": "Elang"}' AS kiri_memuat_kanan;

-- Dibalik arahnya, dokumen kecil tidak memuat dokumen besar.
SELECT '{"merek": "Elang"}'::jsonb @> '{"merek": "Elang", "harga": 750000}' AS kanan_memuat_kiri;

-- <@ adalah @> dengan arah dibalik: kiri terkandung dalam kanan.
SELECT '{"merek": "Elang"}'::jsonb <@ '{"merek": "Elang", "harga": 750000}' AS kiri_terkandung;

-- Angka dibandingkan secara NUMERIK: 25 dan 25.0 dianggap sama.
SELECT '{"stok": 25}'::jsonb @> '{"stok": 25.0}'::jsonb AS angka_sama_numerik;

-- Teks "25" dan angka 25 adalah dua nilai JSON yang berbeda.
SELECT '{"stok": 25}'::jsonb @> '{"stok": "25"}'::jsonb AS teks_vs_angka;

-- Penampung objek bersarang bersifat PARSIAL:
-- cukup pasangan yang disebut hadir, kunci lain boleh ikut.
SELECT info @> '{"spesifikasi": {"berat": 280}}' AS spek_cocok
FROM produk
WHERE id = 1;

-- Bentuk pemakaian paling umum: penyaringan baris di WHERE.
SELECT nama
FROM produk
WHERE info @> '{"tag": ["olahraga"]}'
ORDER BY id;

SELECT nama
FROM produk
WHERE info @> '{"spesifikasi": {"tahan_air": true}}'
ORDER BY id;

keanggotaan.sql

Unduh berkas
sql
-- ? menguji keberadaan kunci pada LEVEL TERATAS dokumen.
SELECT nama
FROM produk
WHERE info ? 'garansi'
ORDER BY id;

-- 'berat' hanya hidup di dalam objek spesifikasi, bukan level teratas;
-- hasilnya nol baris -- inilah jebakan klasik operator ?.
SELECT count(*) AS punya_berat_level_atas
FROM produk
WHERE info ? 'berat';

-- Turun dulu satu tingkat, baru uji keanggotaan pada objek itu.
SELECT count(*) AS punya_berat_di_spek
FROM produk
WHERE info -> 'spesifikasi' ? 'berat';

-- Pada array, ? menguji KEANGGOTAN ELEMEN, bukan kunci.
SELECT nama
FROM produk
WHERE info -> 'tag' ? 'diskon'
ORDER BY id;

-- ?& menuntut SEMUA kunci hadir.
SELECT nama
FROM produk
WHERE info ?& ARRAY['garansi', 'tag']
ORDER BY id;

-- ?| cukup SALAH SATU kunci hadir.
SELECT nama
FROM produk
WHERE info ?| ARRAY['garansi', 'stok']
ORDER BY id;

penggabungan.sql

Unduh berkas
sql
-- || menggabungkan dua objek jsonb;
-- kunci yang bentrok: nilai sisi KANAN yang menang.
SELECT '{"a": 1, "b": 2}'::jsonb || '{"b": 99, "c": 3}' AS kanan_menang;

-- Penggabungan bersifat DANGKAL: objek bersarang diganti utuh,
-- isi lamanya tidak dilebur dengan isi yang baru.
SELECT '{"spesifikasi": {"berat": 280, "bahan": "mesh"}}'::jsonb
       || '{"spesifikasi": {"bahan": "kulit"}}' AS merge_dangkal;

-- Perbarui satu kunci pada dokumen produk.
SELECT info || '{"stok": 10}' AS stok_diperbarui
FROM produk
WHERE id = 1;

-- Array || nilai: elemen baru ditambahkan di ujung array.
SELECT info -> 'tag' || '"murah"' AS tag_diperluas
FROM produk
WHERE id = 1;

penghapusan.sql

Unduh berkas
sql
-- - 'kunci': buang pasangan kunci-nilai dari objek.
SELECT info - 'tag' AS tanpa_tag
FROM produk
WHERE id = 1;

-- - indeks: buang elemen array pada posisi tersebut.
-- Tanda kurung wajib: tanpa itu, ekspresi 'tag' - 0 yang terparse lebih dulu.
SELECT (info -> 'tag') - 0 AS tag_tanpa_pertama
FROM produk
WHERE id = 1;

-- #- path text[]: buang nilai yang berdiri di ujung path.
SELECT info #- '{spesifikasi,berat}' AS tanpa_berat
FROM produk
WHERE id = 1;

-- Path boleh memuat indeks array setelah nama kunci.
SELECT info #- '{tag,0}' AS tag_tanpa_pertama
FROM produk
WHERE id = 1;

-- Penghapusan berlapis: path masuk dua tingkat ke dalam objek.
SELECT '{"a": {"b": {"c": 1}}}'::jsonb #- '{a,b}' AS jalur_dihapus;