Katalog toko daring jarang punya produk yang seragam: kopi punya kolom varietas dan tinggi kemasan, mug punya bahan dan kapasitas, teh mungkin hanya punya berat. Memaksakan semua atribut tersebut ke kolom tetap menghasilkan tabel lebar penuh NULL — tambah satu jenis produk berarti tambah satu kolom lewat ALTER TABLE. MySQL menjawab kebutuhan ini dengan tipe data JSON: satu kolom menyimpan dokumen yang strukturnya boleh berbeda-beda per baris, namun tetap tervalidasi dan terindeks secara internal. Pada chapter ini kita menyimpan dokumen produk ke kolom JSON, mengamati bagaimana MySQL menormalisasi formatnya, membuktikan validasi otomatis yang menolak teks cacat, lalu membandingkan kolom JSON dengan kolom TEXT yang polos.

Menyiapkan Tabel Latihan #

Kita mulai dengan database latihan belajar_json dan satu tabel produk berisi tiga kolom: id, nama, dan atribut bertipe JSON. Tiga produk pertama sengaja memakai struktur dokumen yang berbeda-beda — kopi dan mug memuat dimensi serta tag, sementara teh hanya berupa berat, tag, dan harga:

sql
DROP DATABASE IF EXISTS belajar_json;
CREATE DATABASE belajar_json;
USE belajar_json;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE produk (
  id INT AUTO_INCREMENT PRIMARY KEY,
  nama VARCHAR(100) NOT NULL,
  atribut JSON NOT NULL
);

INSERT INTO produk (nama, atribut) VALUES
  ('Kopi Gayo 250g', '{"warna":"coklat", "berat":250, "harga":55000, "tag":["arabika","toraja"], "dimensi":{"tinggi":12,"diameter":8}}'),
  ('Mug Keramik',    '{ "warna": "putih" ,  "berat": 350, "harga": 45000, "tag": [ "keramik" , "microwave" ], "dimensi": { "tinggi": 10 , "diameter": 8 }, "spesifikasi": { "bahan": "keramik" , "kapasitas": "350 ml" }}'),
  ('Teh Melati 100g', '{"berat": 100, "harga": 25000, "tag": ["teh", "melati"]}');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

Perhatikan bahwa string JSON pada INSERT di atas ditulis dengan gaya yang tak seragam: dokumen Kopi Gayo ditulis rapat tanpa spasi setelah titik dua, dokumen Mug Keramik justru renggang dengan spasi ekstra di sana-sini. Kita bebas mengetik dokumen serapat atau serenggang apa pun asalkan strukturnya sah. Tanda kurung kurawal membuka object, tanda kurung siku membuka array, dan setiap pasangan kunci-nilai dipisah titik dua. Mari kita lihat apa yang tersimpan setelah server memproses ketiga baris tersebut:

sql
SELECT id, nama, atribut FROM produk ORDER BY id\G

Output yang Dihasilkan:

text
*************************** 1. row ***************************
     id: 1
   nama: Kopi Gayo 250g
atribut: {"tag": ["arabika", "toraja"], "berat": 250, "harga": 55000, "warna": "coklat", "dimensi": {"tinggi": 12, "diameter": 8}}
*************************** 2. row ***************************
     id: 2
   nama: Mug Keramik
atribut: {"tag": ["keramik", "microwave"], "berat": 350, "harga": 45000, "warna": "putih", "dimensi": {"tinggi": 10, "diameter": 8}, "spesifikasi": {"bahan": "keramik", "kapasitas": "350 ml"}}
*************************** 3. row ***************************
     id: 3
   nama: Teh Melati 100g
atribut: {"tag": ["teh", "melati"], "berat": 100, "harga": 25000}
3 rows in set (0.00 sec)

MySQL Menyimpan JSON dalam Format Biner Ternormalisasi #

Hasil SELECT di atas memperlihatkan sesuatu yang mengejutkan bagi pemula: teks yang kita masukkan dengan kunci berurutan warna, berat, harga tampil kembali sebagai tag, berat, harga, warna, dimensi — urutannya berubah. Spasi yang tak seragam juga dirapikan menjadi format standar dengan tepat satu spasi setelah setiap titik dua dan koma. Perilaku ini bukan kebetulan: MySQL tidak menyimpan teks JSON mentah, melainkan mengubahnya dulu menjadi format biner internal yang kompak. Dalam format tersebut, kunci object diurutkan menurut panjang kunci lebih dulu, lalu menurut urutan alfabetis, sehingga penampilan kembali hasilnya selalu konsisten.

Dua efek samping normalisasi lainnya juga layak dibuktikan. Pertama, kunci yang ditulis dua kali akan digabung — hanya nilai kemunculan terakhir yang bertahan. Kedua, penulisan apapun yang kita lakukan di input tidak memengaruhi hasil tampilan. Mari kita amati keduanya pada satu dokumen kecil:

sql
SELECT CAST('{"zz": 1, "a": 2, "mmmm": 3, "bb": 4}' AS JSON) AS urutan_kunci;

Output yang Dihasilkan:

text
+---------------------------------------+
| urutan_kunci                          |
+---------------------------------------+
| {"a": 2, "bb": 4, "zz": 1, "mmmm": 3} |
+---------------------------------------+
1 row in set (0.00 sec)
sql
SELECT CAST('{"b": 1, "b": 2}' AS JSON) AS kunci_duplikat;

Output yang Dihasilkan:

text
+----------------+
| kunci_duplikat |
+----------------+
| {"b": 2}       |
+----------------+
1 row in set (0.00 sec)

Dokumen pertama tampil dengan urutan kunci a, bb, zz, mmmm — kunci terpendek selalu lebih dulu, dan kunci sama panjang diurutkan alfabetis. Dokumen kedua menyisakan satu kunci b bernilai 2 karena kemunculan kedua menimpa yang pertama. Karena tampilan selalu dinormalisasi, kita tidak perlu berdebat soal gaya penulisan JSON di tim: hasil di kolom akan selalu seragam, deterministik, dan mudah dibandingkan antar baris.

Validasi Otomatis Menolak JSON yang Cacat #

Kekuatan utama kolom JSON dibanding sekadar menaruh teks adalah validasi: setiap nilai yang masuk harus lolos pemeriksaan parser sebelum disimpan. Teks yang rusak — misalnya object yang kurang kurung tutup — ditolak keras dengan galat sintaks JSON, bukan disimpan diam-diam menjadi sampah data. Mari kita buktikan dengan sengaja menyisakan kurung tutup:

sql
-- @error
INSERT INTO produk (nama, atribut) VALUES
  ('Gula Aren', '{"warna": "coklat keemasan"');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

ERROR 3140 (22032): Invalid JSON text: "Missing a comma or '}' after an object member." at position 27 in value for column 'produk.atribut'.
Bye

Server membalas ERROR 3140 yang menyebut posisi karakter tempat parser kehilangan arah, beserta nama kolom yang menolak nilai tersebut. Posisi dihitung dari awal string JSON, sehingga pesan ini langsung menunjuk tempat yang perlu diperbaiki. Setelah kurung tutup ditambahkan, baris yang sama lolos tanpa keluhan:

sql
INSERT INTO produk (nama, atribut) VALUES
  ('Gula Aren', '{"warna": "coklat keemasan", "berat": 500}');

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

JSON versus Kolom TEXT #

Pertanyaan wajar berikutnya: bukankah hasil akhirnya juga sekadar teks di layar — mengapa tidak pakai TEXT saja? Perbedaannya fundamental. Kolom TEXT menerima byte apa pun tanpa peduli strukturnya; kolom JSON memvalidasi setiap nilai dan menyimpannya dalam format biner yang penelusurannya jauh lebih cepat. Mari kita buktikan kolom TEXT benar-benar menerima JSON cacat tanpa protes:

sql
CREATE TABLE catatan (
  id INT AUTO_INCREMENT PRIMARY KEY,
  isi TEXT
);

INSERT INTO catatan (isi) VALUES
  ('{"warna": "merah"'),
  ('{"berat": 250}');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0
sql
SELECT id, isi, JSON_VALID(isi) AS valid FROM catatan ORDER BY id;

Output yang Dihasilkan:

text
+----+-------------------+-------+
| id | isi               | valid |
+----+-------------------+-------+
|  1 | {"warna": "merah" |     0 |
|  2 | {"berat": 250}    |     1 |
+----+-------------------+-------+
2 rows in set (0.00 sec)

Kedua baris masuk mulus ke kolom isi meskipun baris pertama cacat. Fungsi JSON_VALID kemudian memberitahukan kebenarannya: nilai 0 untuk teks cacat dan 1 untuk teks yang sah. Fungsi inilah senjata pembersih data ketika kita mewarisi kolom TEXT lama berisi dokumen campur aduk — saring dulu dengan JSON_VALID(isi) = 0, perbaiki, baru pindahkan ke kolom JSON.

Sebaliknya, pada tabel produk yang bertipe JSON, semua nilai otomatis sah sehingga JSON_VALID selalu 1 — pemeriksaan manual menjadi tidak perlu:

sql
SELECT id, nama, JSON_VALID(atribut) AS valid FROM produk ORDER BY id;

Output yang Dihasilkan:

text
+----+-----------------+-------+
| id | nama            | valid |
+----+-----------------+-------+
|  1 | Kopi Gayo 250g  |     1 |
|  2 | Mug Keramik     |     1 |
|  3 | Teh Melati 100g |     1 |
|  4 | Gula Aren       |     1 |
+----+-----------------+-------+
4 rows in set (0.00 sec)

Memeriksa Jenis Dokumen dengan JSON_TYPE #

Sebelum mengolah dokumen dari pihak luar, kita sering perlu tahu jenis akarnya: object, array, atau sekadar nilai tunggal. Fungsi JSON_TYPE menjawabnya dengan mengembalikan nama jenis sebagai string. Fungsi ini menerima teks JSON lalu mem-parsing-nya untuk menentukan jenisnya:

sql
SELECT
  JSON_TYPE('{"a": 1}')      AS jenis_object,
  JSON_TYPE('[1, 2, 3]')     AS jenis_array,
  JSON_TYPE('"teks"')        AS jenis_string,
  JSON_TYPE('17')            AS jenis_integer;

Output yang Dihasilkan:

text
+--------------+-------------+--------------+---------------+
| jenis_object | jenis_array | jenis_string | jenis_integer |
+--------------+-------------+--------------+---------------+
| OBJECT       | ARRAY       | STRING       | INTEGER       |
+--------------+-------------+--------------+---------------+
1 row in set (0.00 sec)

Keempat literal tersebut menghasilkan OBJECT, ARRAY, STRING, dan INTEGER — jenis yang paling sering kita jumpai di dokumen nyata. Terapkan pada kolom atribut, seluruh baris produk kita terbaca sebagai OBJECT:

sql
SELECT id, nama, JSON_TYPE(atribut) AS jenis FROM produk ORDER BY id;

Output yang Dihasilkan:

text
+----+-----------------+--------+
| id | nama            | jenis  |
+----+-----------------+--------+
|  1 | Kopi Gayo 250g  | OBJECT |
|  2 | Mug Keramik     | OBJECT |
|  3 | Teh Melati 100g | OBJECT |
|  4 | Gula Aren       | OBJECT |
+----+-----------------+--------+
4 rows in set (0.00 sec)

Kombinasi JSON_VALID dan JSON_TYPE menjadi gerbang pemeriksaan standar ketika menyerap data JSON eksternal: validasi dulu kebenaran teksnya, baru periksa jenis akarnya sebelum diproses lebih lanjut.

Seberapa Besar Dokumen yang Bisa Disimpan #

Kolom JSON tidak punya batas panjang deklaratif seperti VARCHAR(255); ukuran maksimumnya mengikuti variabel server max_allowed_packet — batas ukuran paket komunikasi antara client dan server, yang pada MySQL 8.4 bawaannya 64 MB. Dokumen praktis sebesar itu jauh melampaui kebutuhan atribut produk; yang perlu diingat justru implikasi desainnya: satu baris produk sebaiknya menyimpan atribut yang berskala wajar (puluhan hingga ratusan pasangan kunci-nilai), bukan menjejalkan seluruh katalog ke dalam satu dokumen raksasa. Semakin besar dokumen, semakin mahal biaya parsing dan penyalinannya setiap kali dibaca atau diubah.

Membersihkan Database Latihan #

Rangkaian latihan menyimpan dokumen selesai dan database belajar_json kita jatuhkan kembali:

sql
DROP DATABASE belajar_json;

Output yang Dihasilkan:

text
Query OK, 2 rows affected (0.00 sec)

Referensi #

Kini dokumen produk tersimpan aman di kolom JSON dengan jaminan validasi dan format internal yang rapi. Simpanan saja tentu belum cukup — langkah berikutnya adalah membaca kembali bagian tertentu dari dokumen tanpa harus mengambil seluruh isinya. Chapter berikutnya memperkenalkan operator path -> dan ->> beserta fungsi JSON_EXTRACT untuk menjangkau nilai di kedalaman dokumen.