JSONB operators & JSONPath — ->, ->>, @>, ?, jsonb_path_query
7 JSONB operator cốt lõi + JSONPath PG 12+: cách query nested field, containment, key existence trong TaskFlow custom metadata. Cast numeric, NULL handling, pitfall return type.
TaskFlow có column task_metadata JSONB DEFAULT '{}' lưu custom field per project. Row điển hình trông như thế này:
task_metadata = '{"priority": "high", "labels": ["bug","prod"], "estimate": {"hours": 4, "complexity": "M"}}'
Bạn muốn query "tất cả task có priority = high". Câu lệnh tự nhiên nhất trong đầu:
-- KHONG CHAY DUOC:
SELECT * FROM tasks WHERE task_metadata.priority = 'high';
-- ERROR: column "task_metadata.priority" does not exist
PostgreSQL không có "dot notation" cho JSONB. Mỗi loại truy cập field cần một operator riêng — và việc chọn đúng operator phụ thuộc vào kiểu trả về bạn cần (jsonb hay text), độ sâu path, và mục đích (lấy value, check containment, check key tồn tại). Bài này phủ 7 operator cốt lõi và JSONPath PG 12+.
1. Analogy — Đọc hồ sơ nested nhiều lớp
Hình dung task_metadata là một tập hồ sơ có nhiều ngăn xếp nhau. Mỗi operator là một cách tiếp cận khác nhau để lấy thông tin từ tập hồ sơ đó:
| Operator | Analogy | Trả về | Dùng khi |
|---|---|---|---|
-> | Mở ngăn, lấy cả folder con | jsonb | Cần giữ JSON structure để query tiếp |
->> | Mở ngăn, lấy tờ giấy (text) | text | So sánh string, WHERE clause equality |
#> | Mở nhiều ngăn lồng nhau, lấy folder | jsonb | Nested path sâu, kết quả vẫn là jsonb |
#>> | Mở nhiều ngăn lồng nhau, lấy tờ giấy | text | Nested path sâu, kết quả dùng làm text |
@> | Kiểm tra folder có chứa sub-folder không | boolean | Filter "chứa pattern này không" |
<@ | Kiểm tra folder có nằm trong folder lớn hơn không | boolean | Mirror của @> |
? | Kiểm tra ngăn có tồn tại không | boolean | Key existence check |
Một mũi tên (-> / ->>) cho top-level field. Hai ký tự (#> / #>>) cho nested path dùng array notation. Kết thúc bằng > trả jsonb, kết thúc bằng >> trả text. @> và ? cho boolean check.
2. -> và ->> — Basic getter, return type khác nhau
Đây là hai operator dùng nhiều nhất. Sự khác biệt duy nhất: kiểu trả về.
-- Schema tham khao
-- tasks.task_metadata JSONB, vi du row:
-- '{"priority": "high", "labels": ["bug","prod"], "estimate": {"hours": 4}}'
-- -> tra ve JSONB (co dau ngoac kep khi la string)
SELECT task_metadata->'priority'
FROM tasks WHERE id = 1;
-- Ket qua: "high" (jsonb string, co dau ngoac kep)
-- ->> tra ve TEXT (khong co dau ngoac kep)
SELECT task_metadata->>'priority'
FROM tasks WHERE id = 1;
-- Ket qua: high (text, khong ngoac kep)
-- So sanh dung ->> de lay text
SELECT title, task_metadata->>'priority' AS priority
FROM tasks
WHERE task_metadata->>'priority' = 'high';
-- -> dung de query tiep vao ket qua (chuoi operator)
SELECT task_metadata->'estimate'->'hours'
FROM tasks WHERE id = 1;
-- Ket qua: 4 (jsonb number)
-- ->> de lay text tu array element theo index (0-based)
SELECT task_metadata->'labels'->>0
FROM tasks WHERE id = 1;
-- Ket qua: bug (text, phan tu dau tien cua array)
Tại sao phân biệt quan trọng: -> trả jsonb nên có thể chain tiếp. ->> trả text nên không chain được nhưng dùng trực tiếp trong WHERE với string comparison.
-- Sai: chain tren text (ERROR)
SELECT task_metadata->>'estimate'->>'hours'
FROM tasks WHERE id = 1;
-- ERROR: operator does not exist: text ->> unknown
-- Giai thich: ->> tra text, text khong co operator ->>
-- Dung: chain tren jsonb, lay text o cuoi
SELECT task_metadata->'estimate'->>'hours'
FROM tasks WHERE id = 1;
-- OK: tra ve text "4"
3. #> và #>> — Nested path với array notation
Khi path sâu nhiều level, thay vì chain nhiều ->, dùng #> với path array. Ngắn gọn hơn và ý định rõ hơn.
-- #> lay nested field, tra ve JSONB
SELECT task_metadata #> '{estimate,hours}'
FROM tasks WHERE id = 1;
-- Ket qua: 4 (jsonb)
-- #>> lay nested field, tra ve TEXT
SELECT task_metadata #>> '{estimate,hours}'
FROM tasks WHERE id = 1;
-- Ket qua: 4 (text)
-- Equivalent voi chain -> -> ->>:
SELECT task_metadata->'estimate'->>'hours'
FROM tasks WHERE id = 1;
-- Ket qua: 4 (text) -- giong nhu #>>
-- #>> voi array index (0-based) va path khong ton tai:
SELECT task_metadata #>> '{labels,0}' FROM tasks WHERE id = 1;
-- Ket qua: bug (text, phan tu dau tien)
SELECT task_metadata #>> '{nonexistent,field}' FROM tasks WHERE id = 1;
-- Ket qua: NULL (khong raise error)
-- Hai cach viet tuong duong (chon #>> khi path sau 3+ cap):
-- task_metadata->'level1'->'level2'->>'level3'
-- task_metadata #>> '{level1,level2,level3}'
4. @> và <@ — Containment check
Operator @> (contains) kiểm tra left operand có chứa toàn bộ structure của right operand không. Đây là operator mạnh nhất để filter theo pattern — và là operator mà GIN index hỗ trợ trực tiếp.
-- @> check: left chua right
-- Filter task co priority = high
SELECT title
FROM tasks
WHERE task_metadata @> '{"priority": "high"}';
-- Filter task co label "prod" trong array
SELECT title
FROM tasks
WHERE task_metadata @> '{"labels": ["prod"]}';
-- Luu y: containment array, khong phai exact match
-- {"labels": ["bug","prod"]} @> '{"labels": ["prod"]}' -> TRUE
-- {"labels": ["bug","prod"]} @> '{"labels": ["prod","bug"]}' -> TRUE (thu tu khong quan trong)
-- Nhieu field cung luc (AND semantic)
SELECT title
FROM tasks
WHERE task_metadata @> '{"priority": "high", "estimate": {"complexity": "M"}}';
-- Tat ca field trong right phai co trong left
-- <@ la mirror (it dung hon): left la subset cua right
SELECT '{"a": 1}'::jsonb <@ '{"a": 1, "b": 2}'::jsonb;
-- TRUE: {"a":1} subset cua {"a":1,"b":2}
@> là operator chính để filter JSONB — và là operator mà GIN index (jsonb_ops hoặc jsonb_path_ops) accelerate trực tiếp. Khi bạn thấy WHERE clause filter theo JSONB pattern, dùng @> thay vì ->> + equality — @> scale tốt hơn khi có GIN index. Bài 3 của module này deep dive GIN index.
5. ?, ?|, ?& — Key existence check
Ba operator kiểm tra sự tồn tại của key — không quan tâm đến value, chỉ cần key có trong object không.
-- ? kiem tra mot key ton tai o top-level
SELECT title
FROM tasks
WHERE task_metadata ? 'compliance_tag';
-- Tim tat ca task co field compliance_tag (bat ky value nao)
-- ?| kiem tra it nhat mot trong cac key ton tai (OR)
SELECT title
FROM tasks
WHERE task_metadata ?| ARRAY['compliance_tag', 'gdpr_flag', 'pii_data'];
-- Task co bat ky truong nao trong so nay
-- ?& kiem tra TAT CA cac key ton tai (AND)
SELECT title
FROM tasks
WHERE task_metadata ?& ARRAY['priority', 'estimate', 'labels'];
-- Chi task co du ca ba field nay
-- Ket hop: task co compliance_tag VA priority = high
SELECT title
FROM tasks
WHERE task_metadata ? 'compliance_tag'
AND task_metadata->>'priority' = 'high';
-- ? chi check top-level key, khong check nested
SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'hours';
-- FALSE: 'hours' la nested, khong phai top-level
SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'estimate';
-- TRUE: 'estimate' la top-level key
6. JSONPath PG 12+ — Filter array element và predicate
PostgreSQL 12 (2019) giới thiệu JSONPath — ngôn ngữ query kiểu XQuery/XPath cho JSONB. Ba function chính:
-- jsonb_path_exists: tra ve boolean (giong WHERE clause)
SELECT jsonb_path_exists(
task_metadata,
'$.priority == "high"'
)
FROM tasks WHERE id = 1;
-- TRUE neu priority la "high"
-- jsonb_path_query: tra ve tap ket qua (SET OF jsonb)
SELECT jsonb_path_query(
task_metadata,
'$.labels[*]'
)
FROM tasks WHERE id = 1;
-- Tra ve tung element cua array labels:
-- "bug"
-- "prod"
-- jsonb_path_query_first: chi lay ket qua dau tien
SELECT jsonb_path_query_first(
task_metadata,
'$.labels[0]'
)
FROM tasks WHERE id = 1;
-- "bug"
-- Filter array element theo predicate
-- Tim tat ca task co bat ky label nao la "prod"
SELECT title
FROM tasks
WHERE jsonb_path_exists(
task_metadata,
'$.labels[*] ? (@ == "prod")'
);
-- JSONPath syntax chinh:
-- $.field : top-level field
-- $.a.b : nested field
-- $.arr[*] : tat ca element cua array
-- $.arr[0] : element theo index (0-based)
-- $.arr[*] ? (@ > 5) : filter element lon hon 5
-- $.tasks[*] ? (@.priority == "high") : filter object trong array
Chọn @> khi simple containment (có GIN index). Chọn JSONPath khi predicate phức tạp, numeric comparison, hoặc regex.
7. Numeric và boolean cast cho comparison
->> luôn trả text — so sánh số học trực tiếp sẽ sai về kết quả (alphabet ordering).
-- SAI: so sanh text, '9' > '10' theo ASCII (dung ve text, sai ve so hoc)
WHERE task_metadata->>'score' > '9'
-- DUNG: cast sang numeric
WHERE (task_metadata->>'score')::numeric > 9
-- JSONPath so sanh so truc tiep, khong can cast:
WHERE jsonb_path_exists(task_metadata, '$.estimate.hours > 3')
-- Boolean: ->> tra text 'true'/'false', can cast
WHERE (task_metadata->>'is_archived')::boolean = false
-- Hoac dung -> voi jsonb literal
WHERE task_metadata->'is_archived' = 'false'::jsonb
8. Pitfall — 4 lỗi hay gặp nhất
Pitfall 1 — Nhầm -> vs ->> dẫn đến comparison fail:
-- SAI: so sanh jsonb voi text literal
WHERE task_metadata->'priority' = 'high'
-- ERROR: operator does not exist: jsonb = unknown
-- Giai thich: -> tra jsonb, 'high' la text, khong co operator =
-- DUNG: dung ->> de lay text
WHERE task_metadata->>'priority' = 'high'
-- Hoac dung @> voi jsonb literal
WHERE task_metadata @> '{"priority": "high"}'
Pitfall 2 — Quên cast numeric:
-- SAI: so sanh text theo alphabet
WHERE task_metadata->>'estimate_hours' > '10'
-- '9' > '10' theo text -> TRUE (sai ve so hoc)
-- DUNG: cast sang int/numeric
WHERE (task_metadata->>'estimate_hours')::int > 10
Pitfall 3 — Key case-sensitive:
-- task_metadata = '{"Priority": "high"}'
-- (chu P viet hoa)
SELECT task_metadata->>'priority' FROM tasks WHERE id = 1;
-- NULL: key 'priority' (chu thuong) khong ton tai
SELECT task_metadata->>'Priority' FROM tasks WHERE id = 1;
-- 'high': dung key
-- Khong co case-insensitive operator cho JSONB key
-- Phai dong nhat hoa cach viet key khi insert
Pitfall 4 — NULL handling khi field absent:
-- task_metadata = '{"priority": "high"}' -- khong co 'score'
SELECT task_metadata->>'score' FROM tasks WHERE id = 1;
-- NULL: field khong ton tai tra NULL, khong raise error
-- COALESCE de fallback default:
SELECT COALESCE(task_metadata->>'score', '0') AS score FROM tasks WHERE id = 1;
-- '0'
-- Luu y: WHERE NULL = ... luon FALSE
SELECT * FROM tasks
WHERE task_metadata->>'score' = '0';
-- Khong tra row nao neu score absent (NULL != '0')
-- Phai them OR IS NULL neu muon bao gom row khong co field:
WHERE task_metadata->>'score' = '0'
OR task_metadata->>'score' IS NULL
9. Applied — TaskFlow: filter, aggregate, update
Filter + aggregate theo JSONB field:
-- Filter GDPR task chua done trong 7 ngay
SELECT t.id, t.title, t.task_metadata->>'reviewer_count' AS reviewers
FROM tasks t
WHERE t.task_metadata @> '{"compliance_tag": "GDPR"}'
AND t.status != 'done'
AND t.created_at > now() - INTERVAL '7 days'
ORDER BY t.created_at DESC;
-- Aggregate: dem task theo priority, tinh trung binh gio
SELECT task_metadata->>'priority' AS priority,
COUNT(*) AS task_count,
AVG((task_metadata->>'estimate_hours')::numeric) AS avg_hours
FROM tasks
WHERE task_metadata ? 'priority' -- bo qua row khong co field
GROUP BY task_metadata->>'priority'
ORDER BY task_count DESC;
Update nested field với jsonb_set, merge, xóa key:
-- jsonb_set(target, path, new_value, create_missing)
UPDATE tasks
SET task_metadata = jsonb_set(task_metadata, '{estimate,hours}', '6'::jsonb, true)
WHERE id = 1;
-- Merge / them field moi: || operator
UPDATE tasks
SET task_metadata = task_metadata || '{"reviewed_at": "2025-05-05"}'::jsonb
WHERE id = 1;
-- Key trung lap: gia tri tu right thang
-- Xoa key: - operator
UPDATE tasks
SET task_metadata = task_metadata - 'temp_flag'
WHERE task_metadata ? 'temp_flag';
10. Deep Dive
- PostgreSQL Documentation Ch.9.16 "JSON Functions and Operators" — bảng đầy đủ tất cả operator và function JSONB:
->,->>,#>,#>>,@>,<@,?,?|,?&,||,-,#-,jsonb_set,jsonb_insert,jsonb_strip_nulls, và toàn bộjsonb_path_*family. Đây là nguồn duy nhất cần bookmarks cho JSONB work. - PostgreSQL Documentation Ch.8.14.7 "JSONPath" — JSONPath syntax đầy đủ: path variable, filter expression, arithmetic, string method (
like_regex,starts with), datetime, và modelaxvsstrict. Đọc phần "Strict and Lax Mode" để hiểu NULL handling khác nhau. - PostgreSQL 12 Release Notes — JSONPath — context lịch sử: JSONPath được thêm vào PG 12 (2019) theo chuẩn SQL/JSON Part 2. Release note giải thích design decision và scope.
11. Tóm tắt
->trảjsonb— dùng để chain tiếp hoặc khi cần giữ JSON structure.->>trảtext— dùng trongWHEREequality, string comparison,COALESCE.#>/#>>— nested path dùng array notation'{a,b,c}'; tương đương chain->nhưng ngắn gọn hơn khi path sâu 3+ cấp.@>— containment; left chứa right. Operator chính để filter JSONB pattern, được GIN index accelerate trực tiếp.?/?|/?&— key existence check ở top-level;?|là OR,?&là AND.- JSONPath PG 12+ —
jsonb_path_exists,jsonb_path_query,jsonb_path_query_first; dùng khi cần predicate phức tạp (numeric comparison, filter array element, regex). - Cast là bắt buộc khi so sánh số:
(col->>'field')::int,(col->>'val')::numeric.->>luôn trảtext— so sánh text'9' > '10'là sai về số học. - Key case-sensitive —
metadata->>'Priority'khácmetadata->>'priority'. Đồng nhất hóa khi insert. - Missing key trả NULL — không raise error. Dùng
COALESCEhoặc? keyđể handle absent field. - Update JSONB:
jsonb_setcho nested field,||để merge/thêm field,-để xóa key.
12. Tự kiểm tra
Q1Tại sao `WHERE task_metadata->'priority' = 'high'` báo lỗi, trong khi `WHERE task_metadata->>'priority' = 'high'` chạy bình thường? Viết lại câu query đầu để nó đúng mà không dùng `->>`.▸
Operator -> trả kiểu jsonb, còn literal 'high' là text. PostgreSQL không có operator jsonb = text nên báo lỗi operator does not exist. Operator ->> trả text — cùng type với literal, comparison hoạt động.
Viết lại dùng @> (jsonb so với jsonb):
WHERE task_metadata @> '{"priority": "high"}'Bonus: @> được GIN index accelerate — nhanh hơn ->> + equality trên bảng lớn.
Q2TaskFlow có row: `task_metadata = '{"estimate": {"hours": 12, "complexity": "L"}}'`. Viết query lấy số giờ dưới dạng integer để tính tổng `SUM`. Tại sao không thể dùng `SUM(task_metadata->'estimate'->'hours')` trực tiếp?▸
SUM hoạt động trên kiểu số. Operator -> trả jsonb — PostgreSQL không có SUM(jsonb). Cần cast sang numeric:
SELECT SUM((task_metadata->'estimate'->>'hours')::numeric)
FROM tasks
WHERE task_metadata #>> '{estimate,hours}' IS NOT NULL;Chain: ->'estimate' trả jsonb object → ->>'hours' trả text '12' → ::numeric cast sang số. IS NOT NULL bỏ qua row không có field (tránh cast error trên NULL).
Q3Giải thích semantic của `@>` với array: tại sao `'{"labels": ["bug","prod"]}'::jsonb @> '{"labels": ["prod"]}'::jsonb` là TRUE, nhưng chiều ngược lại là FALSE?▸
@> là containment: left phải chứa toàn bộ right — quan hệ tập hợp con (subset), không phải exact match.
Trường hợp 1: mảng ["bug","prod"] chứa mọi element của ["prod"]? Có → TRUE.
Trường hợp 2: mảng ["prod"] chứa mọi element của ["bug","prod"]? Không — "bug" vắng mặt → FALSE.
Lưu ý: JSONB containment không quan tâm thứ tự phần tử — ["prod","bug"] @> ["bug","prod"] vẫn TRUE. Đây là set membership.
Q4Bạn cần tìm task có `estimate.hours` lớn hơn 8. So sánh hai cách: dùng `->>` với cast, và dùng JSONPath. Khi nào nên dùng cách nào?▸
Cách 1 — ->> với cast: (task_metadata->'estimate'->>'hours')::int > 8. Lưu ý chain đúng: -> trước (jsonb), ->> cuối (text), rồi cast.
Cách 2 — JSONPath: jsonb_path_exists(task_metadata, '$.estimate.hours > 8'). JSONPath hiểu số là số — không cần cast.
Dùng ->> + cast khi logic đơn giản và muốn expression index. Dùng JSONPath khi predicate phức tạp (array filter, regex, datetime) hoặc path sâu 3+ cấp. Cả hai đều không dùng được GIN index cho numeric range — cần expression index riêng nếu query thường xuyên.
Q5Operator `?` chỉ check key ở top-level. Làm thế nào để check sự tồn tại của một nested key, ví dụ kiểm tra field `estimate.complexity` có tồn tại không?▸
Operator ? không có nested variant. Hai cách thay thế:
Cách 1 — #>> IS NOT NULL: WHERE task_metadata #>> '{estimate,complexity}' IS NOT NULL. Trả NULL nếu path vắng mặt, text value nếu tồn tại.
Cách 2 — JSONPath: WHERE jsonb_path_exists(task_metadata, '$.estimate.complexity'). Rõ ý hơn; trả TRUE kể cả khi value là JSON null (khác SQL NULL). Đây là cách khuyến nghị cho nested existence check.
Q6Sau khi đọc bài này, bạn nhận ra team đang dùng `WHERE task_metadata->>'tags' LIKE '%urgent%'` để tìm task urgent. Tại sao cách này sai về semantic và kém hiệu quả? Viết lại đúng.▸
Sai về semantic: Nếu tags là array ["urgent","bug"], thì ->>'tags' trả text đại diện của cả array: '["urgent","bug"]'. LIKE '%urgent%' match substring — cũng match "not_urgent" hay bất kỳ string nào chứa chữ "urgent". False positive khó debug.
Kém hiệu quả: leading wildcard LIKE '%...' không dùng được index — seq scan toàn bảng.
Viết lại đúng:
WHERE task_metadata @> '{"tags": ["urgent"]}'
-- hoac JSONPath:
WHERE jsonb_path_exists(task_metadata, '$.tags[*] ? (@ == "urgent")')@> check exact element membership — không false positive, và được GIN index accelerate (bài 3 của module này).
Bài tiếp theo: GIN index trên JSONB — jsonb_ops vs jsonb_path_ops
Bài này có giúp bạn hiểu bản chất không?