Chapter sebelumnya menutup dengan kesimpulan tegas: jsonb adalah pilihan default untuk hampir semua kebutuhan. Pertanyaan berikutnya bersifat praktis — bagaimana dokumen benar-benar masuk ke kolom itu? Dalam kerja nyata dokumen datang dari berbagai arah: ditulis langsung sebagai literal SQL, diterima sebagai teks mentah dari aplikasi atau file unggahan, dihitung dari kolom lain, atau dimuat sekaligus ratusan baris. Bab ini mempraktikkan keempat jalur tersebut pada katalog produk Toko Maju Jaya, lalu menutup celah yang paling sering melukai tim pengembang: kolom jsonb menerima dokumen bentuk bebas, jadi struktur yang kita anggap wajib — kunci harga, misalnya — harus ditegakkan sendiri lewat CHECK constraint.

Menyisipkan dari Literal String #

Bentuk paling langsung: tulis dokumen sebagai string SQL di dalam INSERT. Server memvalidasi sintaks JSON saat input; teks yang tidak valid ditolak sejak awal (chapter JSON dan JSONB sudah memperlihatkan error-nya). Setelah lolos, dokumen dinormalkan menjadi bentuk biner jsonb.

sql buat-tabel.sql
-- Katalog produk Toko Maju Jaya dengan atribut semi-terstruktur.
-- Kolom info jsonb menerima dokumen bentuk bebas; DEFAULT dokumen kosong.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL DEFAULT '{}'::jsonb
);

-- Menyisipkan dari literal string:
-- dokumen bersarang (spesifikasi berisi dimensi) dan array (tag).
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"}}');

SELECT id, nama, info
FROM produk
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 id |     nama     |                                                                                         info                                                                                         
----+--------------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
  1 | Sepatu Lari  | {"tag": ["olahraga", "diskon"], "stok": 25, "harga": 750000, "merek": "Elang", "spesifikasi": {"bahan": "mesh", "berat": 280, "dimensi": {"lebar": 11, "tinggi": 9, "panjang": 31}}}
  2 | Kemeja Batik | {"tag": ["formal", "diskon"], "stok": 40, "harga": 320000, "merek": "Nusantara", "spesifikasi": {"bahan": "katun", "berat": 220}}
(2 rows)

Dokumen produk 1 sengaja dibuat dua tingkat bersarang: kunci spesifikasi berisi objek lain, yang di dalamnya masih ada objek dimensi berisi panjang, lebar, tinggi; kunci tag berisi array string. Tidak ada deklarasi struktur apa pun sebelumnya — bentuk dokumen sepenuhnya ditentukan oleh isi literal. Perhatikan juga output-nya: jsonb menata ulang urutan kunci (tag paling depan) dan membuang spasi — perilaku normalisasi yang sudah kita bedah di chapter JSON vs JSONB.

Cast dari Teks: Pola Staging #

Aplikasi hampir tidak pernah menulis literal SQL; driver database mengirim dokumen sebagai string teks lewat parameter. Pola yang sama berlaku saat memuat data dari CSV atau file unggahan: teks mentah mendarat dulu di tabel staging bertipe text, lalu dicast ::jsonb saat dipindahkan ke tabel utama — cast inilah yang memicu validasi sintaks dan penolakan dokumen rusak.

sql dari-teks.sql
-- Pola staging: data tiba sebagai TEKS dari aplikasi atau file unggahan,
-- lalu dicast menjadi jsonb saat dipindahkan ke tabel utama.
CREATE TABLE unggah (
    nama      text NOT NULL,
    info_teks text NOT NULL
);

INSERT INTO unggah (nama, info_teks) VALUES
    ('Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}');

-- Cast ::jsonb mengubah teks menjadi dokumen jsonb terurai.
SELECT nama, info_teks::jsonb AS jadi_jsonb
FROM unggah;

INSERT INTO produk (id, nama, info)
SELECT 3, nama, info_teks::jsonb
FROM unggah;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 1
    nama    |                                                                                   jadi_jsonb                                                                                    
------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Tas Ransel | {"tag": ["olahraga", "outdoor"], "stok": 15, "harga": 450000, "merek": "Gunung Jaya", "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}
(1 row)

INSERT 0 1

Query pertama menunjukkan hasil cast info_teks::jsonb: teks yang sama kini tampil sebagai dokumen jsonb yang terurai dan ternormalisasi. Query kedua memindahkan baris tersebut ke tabel produk — nilai yang masuk ke kolom info adalah hasil cast, bukan teks asalnya. Kalau teks di staging ternyata bukan JSON yang valid, INSERT ... SELECT gagal di baris itu dan tidak ada dokumen rusak yang lolos ke tabel utama.

Membangun Dokumen dengan jsonb_build_object #

Jalur ketiga tidak menyentuh literal sama sekali. Fungsi jsonb_build_object menerima pasangan kunci-nilai bergantian dan menyusun objek jsonb; jsonb_build_array melakukan hal yang sama untuk array. Nilai bisa berupa hasil ekspresi SQL apa pun — kolom, fungsi, bahkan dokumen jsonb lain yang dirakit ulang.

-- Membangun dokumen lewat fungsi, bukan literal:
-- jsonb_build_object menyusun objek, jsonb_build_array menyusun array.
INSERT INTO produk (id, nama, info) VALUES
    (4, 'Sendok Set 6',
     jsonb_build_object(
         'merek', 'Sinar Pagi',
         'harga', 65000,
         'stok', 120,
         'tag', jsonb_build_array('dapur'),
         'spesifikasi', jsonb_build_object('berat', 300, 'bahan', 'stainless')
     ));

-- Pola ekstrak-dan-rakit-ulang: bangun dokumen baru dari nilai yang
-- sudah ada di tabel, tanpa menyentuh literal sama sekali.
CREATE TABLE produk_ringkas (
    id       integer PRIMARY KEY,
    ringkas  jsonb NOT NULL
);

INSERT INTO produk_ringkas (id, ringkas)
SELECT id,
       jsonb_build_object(
           'nama',  nama,
           'harga', info -> 'harga',
           'berat', info -> 'spesifikasi' -> 'berat'
       )
FROM produk
WHERE id <= 4;

SELECT id, ringkas
FROM produk_ringkas
ORDER BY id;

Output yang Dihasilkan:

text
INSERT 0 1
CREATE TABLE
INSERT 0 4
 id |                         ringkas                         
----+---------------------------------------------------------
  1 | {"nama": "Sepatu Lari", "berat": 280, "harga": 750000}
  2 | {"nama": "Kemeja Batik", "berat": 220, "harga": 320000}
  3 | {"nama": "Tas Ransel", "berat": 850, "harga": 450000}
  4 | {"nama": "Sendok Set 6", "berat": 300, "harga": 65000}
(4 rows)

Mari kita bedah bagian pentingnya:

  1. INSERT pertama: jsonb_build_object menerima lima pasangan kunci-nilai; nilai untuk tag dan spesifikasi masing-masing dibangun oleh jsonb_build_array dan jsonb_build_object bersarang — struktur tiga tingkat tersusun tanpa satu pun tanda kutip literal.
  2. INSERT kedua memakai pola ekstrak-dan-rakit-ulang: SELECT membaca nilai dari tabel produk — kolom nama, info -> 'harga', info -> 'spesifikasi' -> 'berat' — lalu jsonb_build_object merakitnya menjadi dokumen baru di tabel produk_ringkas.
  3. Operator -> (bukan ->>) dipilih di ekstraksi: hasilnya tetap jsonb, sehingga angka 750000 tersimpan sebagai number JSON, bukan teks "750000" — satu keputusan kecil yang menentukan bisa-tidaknya dokumen ringkas itu dijumlah atau disaring secara numerik nanti.

Output produk_ringkas menunjukkan hasil akhirnya: empat dokumen ringkas, masing-masing dirakit dari nilai baris asalnya.

INSERT Massal dan Nilai DEFAULT #

Katalog nyata diisi ratusan produk sekaligus. Satu perintah INSERT multi-baris menampung dokumen yang strukturnya bervariasi — sebagian punya garansi, sebagian punya kapasitas, sebagian hanya anti_uv — dan tabel tidak keberatan sedikit pun. Baris terakhir sengaja tidak menyertakan kolom info untuk memperlihatkan kerja DEFAULT '{}'::jsonb.

sql bulk-insert.sql
-- INSERT massal: banyak dokumen bervariasi dalam satu perintah.
INSERT INTO produk (id, nama, info) VALUES
    (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}}');

-- Kolom info sengaja dikosongkan: DEFAULT '{}'::jsonb mengisi
-- dokumen kosong supaya NOT NULL tetap terpenuhi.
INSERT INTO produk (id, nama) VALUES
    (9, 'Kain Lap');

SELECT id, nama, info
FROM produk
ORDER BY id;

Output yang Dihasilkan:

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

Baris 5 sampai 8 masuk lewat satu perintah dengan empat dokumen yang bentuknya berbeda-beda — inilah keunggulan data semi-terstruktur: tidak ada ALTER TABLE untuk atribut baru, tidak ada kolom kosong untuk atribut yang tak relevan. Baris 9, Kain Lap, menampilkan {} di kolom info: kolom tersebut dideklarasikan NOT NULL DEFAULT '{}'::jsonb, jadi baris yang tidak menyertakan info tetap lolos dengan dokumen kosong, bukan NULL.

Menegakkan Struktur dengan CHECK Constraint #

Kebebasan bentuk menyimpan satu risiko: tidak ada yang mencegah dokumen tanpa kunci penting masuk. Produk tanpa harga bisa lolos bertahun-tahun sampai query laporan keuangan mulai menjatuhkan NULL diam-diam. Solusi bawaan PostgreSQL adalah CHECK constraint yang menguji struktur dokumen — operator ? (dibahas penuh di chapter Operator JSON dan JSONB) memeriksa keberadaan kunci pada level teratas dokumen.

Pemasangannya sendiri menuntut kedisiplinan: ADD CONSTRAINT memvalidasi seluruh baris lama saat aturan dipasang, jadi baris 9 yang masih memuat dokumen kosong harus dilengkapi dulu.

sql constraint.sql
-- Baris 9 masih memuat dokumen kosong hasil DEFAULT.
-- ADD CONSTRAINT memeriksa seluruh baris lama saat aturan dipasang,
-- jadi lengkapi dulu dokumennya sebelum menegakkan aturan.
UPDATE produk
SET info = jsonb_build_object(
    'merek', 'Sinar Pagi',
    'harga', 15000,
    'stok', 300,
    'tag', jsonb_build_array('dapur'),
    'spesifikasi', jsonb_build_object('berat', 40, 'bahan', 'mikrofiber')
)
WHERE id = 9;

-- Aturan struktur: setiap dokumen WAJIB memiliki kunci "harga".
ALTER TABLE produk
    ADD CONSTRAINT produk_info_harga_check CHECK (info ? 'harga');

Output yang Dihasilkan:

text
UPDATE 1
ALTER TABLE

Sejak perintah ALTER TABLE itu, setiap tulisan baru ke tabel produk melewati pemeriksaan info ? 'harga'. Sengketa berikutnya membuktikannya — dokumen baru tanpa kunci harga ditolak mentah-mentah oleh server:

-- Dokumen tanpa kunci "harga" ditolak oleh CHECK constraint.
INSERT INTO produk (id, nama, info) VALUES
    (10, 'Kain Lap Premium',
     '{"merek": "Sinar Pagi", "stok": 50, "tag": ["dapur"], "spesifikasi": {"berat": 35, "bahan": "katun"}}');

Output yang Dihasilkan:

text
psql:constraint-violasi.sql:4: ERROR:  new row for relation "produk" violates check constraint "produk_info_harga_check"
DETAIL:  Failing row contains (10, Kain Lap Premium, {"tag": ["dapur"], "stok": 50, "merek": "Sinar Pagi", "spesifika...).

Error violates check constraint "produk_info_harga_check" datang lengkap dengan DETAIL yang menampilkan isi baris yang gagal — nama constraint yang eksplisit membuat log error di aplikasi langsung menunjuk aturan yang dilanggar. Dokumen yang memenuhi aturan lolos tanpa hambatan:

-- Dokumen yang memuat kunci "harga" lolos dari constraint yang sama.
INSERT INTO produk (id, nama, info) VALUES
    (10, 'Kain Lap Premium',
     '{"merek": "Sinar Pagi", "harga": 15000, "stok": 50, "tag": ["dapur"], "spesifikasi": {"berat": 35, "bahan": "katun"}}');

SELECT id, nama, info -> 'harga' AS harga
FROM produk
WHERE id >= 9
ORDER BY id;

Output yang Dihasilkan:

text
INSERT 0 1
 id |       nama       | harga 
----+------------------+-------
  9 | Kain Lap         | 15000
 10 | Kain Lap Premium | 15000
(2 rows)

Empat jalur masuk kini terbuka — literal, cast, fungsi pembangun, INSERT massal — dan aturan struktur terpasang di pintu. Dokumen yang tersimpan hanyalah separuh pekerjaan; langkah berikutnya adalah membacanya kembali: membedakan -> dari ->>, menelusuri path bertingkat, dan mengambil nilai dengan tipe data yang benar — materi chapter Membaca Nilai JSON.

Referensi #

Berkas Sumber

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

bangun-dokumen.sql

Unduh berkas
sql
-- Membangun dokumen lewat fungsi, bukan literal:
-- jsonb_build_object menyusun objek, jsonb_build_array menyusun array.
INSERT INTO produk (id, nama, info) VALUES
    (4, 'Sendok Set 6',
     jsonb_build_object(
         'merek', 'Sinar Pagi',
         'harga', 65000,
         'stok', 120,
         'tag', jsonb_build_array('dapur'),
         'spesifikasi', jsonb_build_object('berat', 300, 'bahan', 'stainless')
     ));

-- Pola ekstrak-dan-rakit-ulang: bangun dokumen baru dari nilai yang
-- sudah ada di tabel, tanpa menyentuh literal sama sekali.
CREATE TABLE produk_ringkas (
    id       integer PRIMARY KEY,
    ringkas  jsonb NOT NULL
);

INSERT INTO produk_ringkas (id, ringkas)
SELECT id,
       jsonb_build_object(
           'nama',  nama,
           'harga', info -> 'harga',
           'berat', info -> 'spesifikasi' -> 'berat'
       )
FROM produk
WHERE id <= 4;

SELECT id, ringkas
FROM produk_ringkas
ORDER BY id;

buat-tabel.sql

Unduh berkas
sql
-- Katalog produk Toko Maju Jaya dengan atribut semi-terstruktur.
-- Kolom info jsonb menerima dokumen bentuk bebas; DEFAULT dokumen kosong.
CREATE TABLE produk (
    id    integer PRIMARY KEY,
    nama  text NOT NULL,
    info  jsonb NOT NULL DEFAULT '{}'::jsonb
);

-- Menyisipkan dari literal string:
-- dokumen bersarang (spesifikasi berisi dimensi) dan array (tag).
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"}}');

SELECT id, nama, info
FROM produk
ORDER BY id;

bulk-insert.sql

Unduh berkas
sql
-- INSERT massal: banyak dokumen bervariasi dalam satu perintah.
INSERT INTO produk (id, nama, info) VALUES
    (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}}');

-- Kolom info sengaja dikosongkan: DEFAULT '{}'::jsonb mengisi
-- dokumen kosong supaya NOT NULL tetap terpenuhi.
INSERT INTO produk (id, nama) VALUES
    (9, 'Kain Lap');

SELECT id, nama, info
FROM produk
ORDER BY id;

constraint-hasil.sql

Unduh berkas
sql
-- Dokumen yang memuat kunci "harga" lolos dari constraint yang sama.
INSERT INTO produk (id, nama, info) VALUES
    (10, 'Kain Lap Premium',
     '{"merek": "Sinar Pagi", "harga": 15000, "stok": 50, "tag": ["dapur"], "spesifikasi": {"berat": 35, "bahan": "katun"}}');

SELECT id, nama, info -> 'harga' AS harga
FROM produk
WHERE id >= 9
ORDER BY id;

constraint-violasi.sql

Unduh berkas
sql
-- Dokumen tanpa kunci "harga" ditolak oleh CHECK constraint.
INSERT INTO produk (id, nama, info) VALUES
    (10, 'Kain Lap Premium',
     '{"merek": "Sinar Pagi", "stok": 50, "tag": ["dapur"], "spesifikasi": {"berat": 35, "bahan": "katun"}}');

constraint.sql

Unduh berkas
sql
-- Baris 9 masih memuat dokumen kosong hasil DEFAULT.
-- ADD CONSTRAINT memeriksa seluruh baris lama saat aturan dipasang,
-- jadi lengkapi dulu dokumennya sebelum menegakkan aturan.
UPDATE produk
SET info = jsonb_build_object(
    'merek', 'Sinar Pagi',
    'harga', 15000,
    'stok', 300,
    'tag', jsonb_build_array('dapur'),
    'spesifikasi', jsonb_build_object('berat', 40, 'bahan', 'mikrofiber')
)
WHERE id = 9;

-- Aturan struktur: setiap dokumen WAJIB memiliki kunci "harga".
ALTER TABLE produk
    ADD CONSTRAINT produk_info_harga_check CHECK (info ? 'harga');

dari-teks.sql

Unduh berkas
sql
-- Pola staging: data tiba sebagai TEKS dari aplikasi atau file unggahan,
-- lalu dicast menjadi jsonb saat dipindahkan ke tabel utama.
CREATE TABLE unggah (
    nama      text NOT NULL,
    info_teks text NOT NULL
);

INSERT INTO unggah (nama, info_teks) VALUES
    ('Tas Ransel',
     '{"merek": "Gunung Jaya", "harga": 450000, "stok": 15, "tag": ["olahraga", "outdoor"], "garansi": "12 bulan", "spesifikasi": {"berat": 850, "kapasitas": 30, "tahan_air": true}}');

-- Cast ::jsonb mengubah teks menjadi dokumen jsonb terurai.
SELECT nama, info_teks::jsonb AS jadi_jsonb
FROM unggah;

INSERT INTO produk (id, nama, info)
SELECT 3, nama, info_teks::jsonb
FROM unggah;