PostgreSQL — Advanced & Scale
2/7
Bài 2 / 7~12 phútJSONB, full-text & pgvectorMiễn phí lượt xem

JSONB operators & JSONPath — ->, ->>, @>, ?, jsonb_path_query

PostgreSQL không có dot notation cho JSONB: mỗi cách đọc field là một operator riêng, khác nhau ở kiểu trả về. Bài mổ 7 operator cốt lõi, JSONPath và bẫy cast số.

TL;DR: PostgreSQL không có task_metadata.priority. Muốn đọc field JSONB bạn chọn operator theo hai câu hỏi: cần kiểu gì trả về, và muốn làm gì. -> trả jsonb để chain tiếp, ->> trả text để so sánh trong WHERE; #>/#>> là bản nested với path array; @> hỏi "document có chứa mảnh này không" và là operator duy nhất GIN index tăng tốc trực tiếp; ? hỏi key có tồn tại ở top-level không. JSONPath (jsonb_path_match, jsonb_path_query) lo predicate phức tạp và so sánh số không cần cast. Hai bẫy hay gặp: ->> luôn trả text nên '9' > '10' là đúng theo bảng chữ cái, và field vắng mặt trả NULL chứ không lỗi.

TaskFlow có column task_metadata JSONB DEFAULT '{}' lưu custom field per project. Row điển hình:

task_metadata = '{"priority": "high", "labels": ["bug","prod"], "estimate": {"hours": 4, "complexity": "M"}}'

Bạn muốn "tất cả task có priority = high". Câu lệnh tự nhiên nhất trong đầu:

SQL
-- 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 kiểu truy cập là một operator riêng, và chọn đúng operator phụ thuộc vào kiểu trả về bạn cần, độ sâu path, và mục đích (lấy value, check containment, check key tồn tại).

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 lồng nhau. Mỗi operator là một cách lấy thông tin từ tập hồ sơ đó:

OperatorAnalogyTrả vềDùng khi
->Mở ngăn, lấy cả folder conjsonbCần giữ JSON structure để query tiếp
->>Mở ngăn, lấy tờ giấy (text)textSo sánh string, WHERE equality
#>Mở nhiều ngăn lồng nhau, lấy folderjsonbNested path sâu, kết quả vẫn là jsonb
#>>Mở nhiều ngăn lồng nhau, lấy tờ giấytextNested path sâu, kết quả dùng làm text
@>Folder có chứa sub-folder này khôngbooleanFilter "chứa pattern này không"
<@Folder có nằm trong folder lớn hơn khôngbooleanMirror của @>
?Ngăn có tồn tại khôngbooleanKey existence check
💡 Quy tắc chọn operator

Một mũi tên (-> / ->>) cho top-level field. Dấu thăng (#> / #>>) cho nested path dùng array notation. Kết thúc bằng > trả jsonb, bằng >> trả text. @>? cho boolean check.

Cây task_metadata ba tầng và năm operator chỉ vào đúng tầng mình chạm: mũi tên đơn và đôi cùng vào ngăn priority nhưng trả jsonb hoặc text, chứa hỏi array labels, dấu hỏi hỏi key tầng đỉnh estimate, đường dẫn hai tầng đi tới hours

2. ->->> — cùng một ngăn, khác kiểu trả về

Hai operator dùng nhiều nhất, khác nhau đúng một chỗ: kiểu trả về. Nhớ lại bài 01: JSONB đã sort key và có bảng offset, nên cả hai đều nhảy thẳng tới key mà không parse lại.

SQL
-- 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;
-- "high"   (jsonb string)

-- ->> tra ve TEXT (khong co dau ngoac kep)
SELECT task_metadata->>'priority' FROM tasks WHERE id = 1;
-- high     (text)

-- ->> de so sanh trong WHERE
SELECT title, task_metadata->>'priority' AS priority
FROM tasks
WHERE task_metadata->>'priority' = 'high';

-- -> de chain tiep
SELECT task_metadata->'estimate'->'hours' FROM tasks WHERE id = 1;
-- 4  (jsonb number)

-- ->> voi array index (0-based)
SELECT task_metadata->'labels'->>0 FROM tasks WHERE id = 1;
-- bug

Vì sao phân biệt quan trọng: -> trả jsonb nên chain tiếp được; ->> trả text nên không chain được nhưng dùng thẳng trong WHERE.

SQL
-- Sai: chain tren text (ERROR)
SELECT task_metadata->>'estimate'->>'hours' FROM tasks WHERE id = 1;
-- ERROR: operator does not exist: text ->> unknown

-- Dung: chain tren jsonb, lay text o cuoi
SELECT task_metadata->'estimate'->>'hours' FROM tasks WHERE id = 1;
-- "4" (text)

Thử ngẫmtask_metadata->>'estimate'->>'hours' báo lỗi operator does not exist: text ->> unknown. Muốn lấy field ba cấp làm text, dấu >> chỉ được đặt ở đâu trong chuỗi?

3. #>#>> — nested path với array notation

Khi path sâu nhiều cấp, thay vì chain nhiều ->, dùng #> với path array. Ngắn hơn và ý định rõ hơn.

SQL
-- #> lay nested field, tra ve JSONB
SELECT task_metadata #> '{estimate,hours}' FROM tasks WHERE id = 1;
-- 4  (jsonb)

-- #>> lay nested field, tra ve TEXT (tuong duong ->'estimate'->>'hours')
SELECT task_metadata #>> '{estimate,hours}' FROM tasks WHERE id = 1;
-- 4  (text)

-- Array index trong path
SELECT task_metadata #>> '{labels,0}' FROM tasks WHERE id = 1;
-- bug

-- Path khong ton tai: NULL, khong raise error
SELECT task_metadata #>> '{nonexistent,field}' FROM tasks WHERE id = 1;
-- NULL

4. Vì sao nên filter JSONB bằng @> thay vì ->> bằng?

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.

SQL
-- 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"]}';
-- 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"}}';

-- <@ la mirror (it dung hon): left la subset cua right
SELECT '{"a": 1}'::jsonb <@ '{"a": 1, "b": 2}'::jsonb;
-- TRUE
💡 @> với GIN index

@> là operator mà GIN index (jsonb_ops hoặc jsonb_path_ops) tăng tốc trực tiếp. Khi thấy WHERE lọc theo pattern JSONB, viết bằng @> thay vì ->> cộng equality, vì @> scale tốt hơn khi có GIN. Bài 03 mổ GIN index.

Thử ngẫmbảng tasks sắp có 5 triệu row. Giữa WHERE task_metadata->>'priority' = 'high'WHERE task_metadata @> '{"priority": "high"}', GIN index tăng tốc câu nào, và câu còn lại thì sao?

5. ?, ?|, ?& — key có tồn tại không

Ba operator kiểm tra sự tồn tại của key, không quan tâm value.

SQL
-- ? mot key ton tai o top-level
SELECT title FROM tasks WHERE task_metadata ? 'compliance_tag';

-- ?| it nhat mot trong cac key (OR)
SELECT title FROM tasks
WHERE task_metadata ?| ARRAY['compliance_tag', 'gdpr_flag', 'pii_data'];

-- ?& TAT CA cac key (AND)
SELECT title FROM tasks
WHERE task_metadata ?& ARRAY['priority', 'estimate', 'labels'];

-- ? chi check top-level key, khong check nested
SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'hours';
-- FALSE: 'hours' la nested

SELECT '{"estimate": {"hours": 4}}'::jsonb ? 'estimate';
-- TRUE

6. JSONPath (PG 12+) — filter array element và predicate

PostgreSQL 12 (2019) thêm JSONPath, ngôn ngữ query kiểu XPath cho JSONB, theo chuẩn SQL/JSON. Ba hàm chính:

SQL
-- jsonb_path_match (hoac toan tu @@): predicate tra ve boolean
SELECT jsonb_path_match(task_metadata, '$.priority == "high"')
FROM tasks WHERE id = 1;
-- true

-- jsonb_path_exists (hoac @?): path co tra ve item nao khong
SELECT jsonb_path_exists(task_metadata, '$.estimate.hours')
FROM tasks WHERE id = 1;
-- true

-- jsonb_path_query: tra ve SET OF jsonb
SELECT jsonb_path_query(task_metadata, '$.labels[*]')
FROM tasks WHERE id = 1;
-- "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
SELECT title FROM tasks
WHERE jsonb_path_exists(task_metadata, '$.labels[*] ? (@ == "prod")');

-- JSONPath syntax chinh:
-- $.field                 : top-level field
-- $.a.b                   : nested field
-- $.arr[*]                : moi element cua array
-- $.arr[*] ? (@ > 5)      : filter element lon hon 5
-- $.tasks[*] ? (@.priority == "high")  : filter object trong array

Chọn @> khi containment đơn giản (có GIN index). Chọn JSONPath khi predicate phức tạp, so sánh số, hoặc regex.

Một bẫy đáng nhớ: jsonb_path_exists(col, '$.priority == "high"') luôn trả true, vì biểu thức predicate luôn sinh ra đúng một item (true/false) và exists chỉ hỏi "có item không". Predicate trần thì dùng jsonb_path_match hoặc @@; với jsonb_path_exists/@? phải viết dạng filter '$.priority ? (@ == "high")'.

7. Cast số và boolean — ->> luôn trả text

Trước khi đọc tiếp, hãy đoán: với ->>'score' trả text, câu WHERE task_metadata->>'score' > '9' có lấy được task có score 10 không?

SQL
-- So sanh text: '10' < '9' theo bang chu cai
WHERE task_metadata->>'score' > '9'

-- DUNG: cast sang numeric
WHERE (task_metadata->>'score')::numeric > 9

-- JSONPath hieu so la so, khong can cast
WHERE task_metadata @@ '$.estimate.hours > 3'

-- Boolean: ->> tra text 'true'/'false', can cast
WHERE (task_metadata->>'is_archived')::boolean = false

-- Hoac so sanh jsonb voi jsonb literal
WHERE task_metadata->'is_archived' = 'false'::jsonb

Task có score 10 bị loại: '10' > '9' là sai vì so sánh dừng ở ký tự đầu ('1' đứng trước '9'). Không có lỗi, không có cảnh báo, chỉ có report thiếu đúng những task điểm cao.

8. Pitfall — return type, key hoa/thường, NULL

Pitfall — 3 lỗi hay gặp nhất

Pitfall 1 — nhầm -> với ->> khi so sánh:

SQL
-- SAI: so sanh jsonb voi text literal
WHERE task_metadata->'priority' = 'high'
-- ERROR: operator does not exist: jsonb = unknown

-- DUNG: ->> de lay text, hoac @> voi jsonb literal
WHERE task_metadata->>'priority' = 'high'
WHERE task_metadata @> '{"priority": "high"}'

Pitfall 2 — key phân biệt hoa/thường:

SQL
-- task_metadata = '{"Priority": "high"}'
SELECT task_metadata->>'priority' FROM tasks WHERE id = 1;
-- NULL: key 'priority' (chu thuong) khong ton tai
-- Khong co case-insensitive operator cho key -> dong nhat cach viet khi insert

Pitfall 3 — field vắng mặt trả NULL, và NULL không bằng gì cả:

SQL
-- task_metadata = '{"priority": "high"}'  -- khong co 'score'
SELECT task_metadata->>'score' FROM tasks WHERE id = 1;
-- NULL (khong raise error)

-- COALESCE de fallback default
SELECT COALESCE(task_metadata->>'score', '0') AS score FROM tasks;

-- WHERE NULL = '0' luon FALSE -> row khong co field bi loai
WHERE task_metadata->>'score' = '0'
   OR task_metadata->>'score' IS NULL

9. Applied — TaskFlow: filter, aggregate, update

SQL
-- Aggregate theo JSONB field
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: 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: || (key trung -> gia tri ben phai thang)
UPDATE tasks
SET task_metadata = task_metadata || '{"reviewed_at": "2025-05-05"}'::jsonb
WHERE id = 1;

-- Xoa key: -
UPDATE tasks
SET task_metadata = task_metadata - 'temp_flag'
WHERE task_metadata ? 'temp_flag';

10. Deep Dive

Deep Dive — JSONB operator reference chính thức

11. Liên hệ các bài khác

12. Tóm tắt

  • NULL bất ngờ từ ->> thường do path sai hoặc key thiếu: kiểm bằng jsonb_path_exists(col, '$.a.b') trước khi nghi ngờ dữ liệu.
  • Trước khi so sánh số từ ->>, cast: (col->>'hours')::int > 8; hoặc chuyển sang col @@ '$.hours > 8' (predicate trần đi với @@/jsonb_path_match, không phải jsonb_path_exists).
  • Debug "query không trả row": kiểm tra key hoa/thường và field vắng mặt (NULL), thêm OR ... IS NULL nếu muốn gộp row thiếu field.
  • Nested existence: col #>> '{a,b}' IS NOT NULL hoặc jsonb_path_exists(col, '$.a.b'); ? không có bản nested.
  • Sửa JSONB tại chỗ: jsonb_set cho nested, || để merge, - để xoá key.

13. Tự kiểm tra

Tự kiểm tra
0/6 câu đã trả lời
  1. Q1
    Tạ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 đầu cho đúng mà không dùng `->>`.
  2. Q2
    Row có `task_metadata = '{"estimate": {"hours": 12, "complexity": "L"}}'`. Viết query tính `SUM` số giờ. Tại sao không dùng `SUM(task_metadata->'estimate'->'hours')` trực tiếp?
  3. Q3
    Tại sao `'{"labels": ["bug","prod"]}'::jsonb @> '{"labels": ["prod"]}'::jsonb` là TRUE, nhưng chiều ngược lại là FALSE?
  4. Q4
    Bạn cần tìm task có `estimate.hours` lớn hơn 8. So sánh cách dùng `->>` với cast và cách dùng JSONPath. Khi nào chọn cách nào?
  5. Q5
    Operator `?` chỉ check key top-level. Làm sao kiểm tra field `estimate.complexity` có tồn tại không?
  6. Q6
    Team đang dùng `WHERE task_metadata->>'tags' LIKE '%urgent%'` để tìm task urgent. Vì sao cách này sai về ngữ nghĩa và kém hiệu quả? Viết lại đúng.

Bài tiếp theo: GIN index trên JSONB — jsonb_ops vs jsonb_path_ops

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

GIN index cho JSONB — jsonb_ops vs jsonb_path_ops, partial GIN