Danh sách bài viết

Bài 60: PostgreSQL JSONB + Array Indexing

Bài 60 của series Rust RESTful API — bài CUỐI Group 6 PostgreSQL + sqlx (10/10): phân biệt JSONB binary format vs JSON text format (JSONB parse lúc insert lưu binary tree query nhanh + index GIN, JSON parse mỗi lần read insert nhanh hơn nhưng query chậm không index hiệu quả, Shop API lock JSONB MANDATORY mọi flexible schema column); 6 JSONB operator core — -> field access trả JSONB, ->> field access trả TEXT, @> contains (left chứa right), <@ contained by (right chứa left), ? key exists top-level, ?| ANY key exists, ?& ALL keys exist; 2 cách create GIN index JSONB — default jsonb_ops support tất cả operator (size lớn, slow) vs jsonb_path_ops chỉ support @> (smaller + faster), Shop API decision payment_payload dùng jsonb_path_ops vì chỉ query @> còn products.metadata dùng default jsonb_ops vì cần ? check key existence; JSONPath SQL/JSON Postgres 12+ với operator #> extract value path JSONB + #>> extract value path TEXT cho query sâu analytics; 4 array operator Postgres — = ANY element trong array (B52 lock), @> array contains tất cả element, && array overlap ít nhất 1 element, || array concat; quy tắc Array vs JSONB Shop API (1D string array → TEXT[] tags, variable schema → JSONB metadata, hierarchical → JSONB, sortable → array, full-text search inside → JSONB GIN); migration 5 mới add_products_metadata thêm cột metadata JSONB NOT NULL DEFAULT '{}' + 2 index (GIN default + partial expression index trên metadata->>'warranty_months'); ProductRow extend field metadata: serde_json::Value, CreateProductDto thêm metadata: BTreeMap<String, Value>, ProductFilter extend 2 filter has_metadata_key (?) + metadata_contains (@>) endpoint extend; payment query pattern find_payments_by_stripe_intent dùng payment_payload @> $1 lookup theo nested key. HOÀN THÀNH Group 6 PostgreSQL + sqlx (10/10 bài) — foundation ready G7 CRUD đầy đủ. Updated: migration 5 add_products_metadata.sql, extend crates/shop-db/src/products.rs, extend crates/shop-db/src/payments.rs, extend crates/shop-common/src/dto/product.rs.

15/06/2026
14 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Phân biệt JSONB vs JSON Postgres về storage, query speed và index khả dụng.
  • Hiểu 6 JSONB operator core: ->, ->>, @>, <@, ?, ?|, ?&.
  • Dùng JSONB path expression với #>, #>>jsonb_path_query.
  • Implement GIN index cho JSONB: phân biệt default jsonb_ops vs jsonb_path_ops.
  • Hiểu 4 array operator Postgres: = ANY, @>, &&, ||.
  • Viết migration mới thêm cột metadata JSONB cho products + 2 index.
  • Implement metadata search endpoint + payment_payload query pattern Shop API.
  • Hoàn thành Group 6 PostgreSQL + sqlx (10/10 bài) → sẵn sàng sang Group 7 CRUD đầy đủ.
2

JSONB vs JSON — Khác Biệt Cốt Lõi

PostgreSQL hỗ trợ 2 kiểu dữ liệu JSON với chiến lược lưu trữ khác nhau:

  • JSON (kiểu text): lưu nguyên văn bản gốc, parse lại mỗi lần truy vấn. Insert nhanh vì chỉ validate cú pháp. Query chậm vì parse lặp + không có index hiệu quả. Preserve thứ tự key + khoảng trắng nguyên bản.
  • JSONB (kiểu binary): parse một lần lúc INSERT, lưu dưới dạng cây nhị phân. Insert chậm hơn một chút, nhưng query nhanh, hỗ trợ index GIN, so sánh giá trị nhanh. KHÔNG preserve thứ tự key + KHÔNG giữ khoảng trắng dư.

Mô hình storage rút gọn:

JSON  →  TEXT internal, parse on every read
JSONB →  Binary tree, parse once at write

Lock decision Shop API: dùng JSONB cho mọi flexible schema column (metadata sản phẩm, payment_payload, audit log, user preferences). Lý do: query pattern (filter, sort, contains) là yêu cầu chính của API, đánh đổi vài microsecond insert lấy index GIN xứng đáng. JSON kiểu text chỉ phù hợp khi cần archive nguyên format gốc (rất hiếm — gần như không xảy ra trong Shop API).

So sánh chi tiết:

Tiêu chí                  | JSON         | JSONB
Storage                   | Text raw     | Binary tree
Insert                    | Nhanh        | Chậm hơn ~5-10%
Read/Query                | Chậm (parse) | Nhanh
Index GIN                 | KHÔNG        | CÓ
Operator @>, ?, ->        | KHÔNG hiệu quả | Native + nhanh
Preserve key order        | CÓ           | KHÔNG
Preserve whitespace       | CÓ           | KHÔNG
Duplicate key             | Giữ lại      | Chỉ giữ key cuối

Khi nào dùng JSON kiểu text: log raw từ third-party, audit JSON nguyên bản phải so sánh byte-by-byte (vd webhook signature verify cần giữ nguyên payload gốc). Trong Shop API, webhook signature verify lưu raw bytes (BYTEA) thay JSON text, nên cũng không cần kiểu JSON. Kết luận: JSONB MANDATORY cho mọi cột flexible schema từ B60 trở đi.

3

6 JSONB Operator Cơ Bản

JSONB Postgres cung cấp 7 operator chính (gọi tròn là "6 core" do <@ đối xứng @>). Dữ liệu mẫu: cột payment_payload bảng payments (lock B43+B54) chứa {"card_brand": "visa", "card": {"last4": "4242", "exp_month": 12}}.

-> field/index access (trả JSONB) — truy cập key trong object hoặc index trong array, kết quả vẫn là JSONB nên có thể chain tiếp:

-- Truy cập key object → trả JSONB
SELECT payment_payload->'card' FROM payments;
-- {"last4": "4242", "exp_month": 12}    (JSONB)

-- Chain tiếp key con
SELECT payment_payload->'card'->'last4' FROM payments;
-- "4242"   (JSONB string — vẫn có dấu nháy ngoài)

-- Array index (zero-based)
SELECT '["a", "b", "c"]'::jsonb->1;
-- "b"     (JSONB)

->> field access (trả TEXT) — giống -> nhưng kết quả là TEXT thuần, đã strip dấu nháy. Pattern dùng khi cần so sánh với chuỗi Rust hoặc convert sang kiểu khác:

SELECT payment_payload->>'card_brand' FROM payments;
-- visa     (TEXT — KHÔNG có dấu nháy)

-- Cast sang INT
SELECT (payment_payload->'card'->>'exp_month')::int FROM payments;
-- 12

@> contains (left chứa right) — kiểm tra JSONB bên trái có chứa cấu trúc JSONB bên phải không. Đây là operator quan trọng nhất cho lookup theo nested key:

SELECT * FROM payments
WHERE payment_payload @> '{"card_brand": "visa"}'::jsonb;
-- Match mọi payment có key card_brand = "visa"

-- Match nested deep
SELECT * FROM payments
WHERE payment_payload @> '{"card": {"last4": "4242"}}'::jsonb;

<@ contained by (right chứa left) — đối xứng @>, ít dùng trong Shop API.

? key exists (top-level) — kiểm tra key tồn tại ở mức top-level của JSONB object:

SELECT * FROM products WHERE metadata ? 'warranty_months';
-- Match khi metadata có key warranty_months ở top-level

?| ANY key exists — match khi có ít nhất 1 key trong array tồn tại:

SELECT * FROM products
WHERE metadata ?| ARRAY['warranty_months', 'discount_code'];

?& ALL keys exist — match khi tất cả key trong array đều tồn tại:

SELECT * FROM products
WHERE metadata ?& ARRAY['warranty_months', 'color'];

Bảng so sánh nhanh:

Operator | Mô tả                          | Return type
->       | Get value at key/index         | JSONB
->>      | Get value at key/index         | TEXT
@>       | Contains                       | BOOL
<@       | Contained by                   | BOOL
?        | Key exists (top-level)         | BOOL
?|       | Any key in array exists        | BOOL
?&       | All keys in array exist        | BOOL

Lưu ý: ?, ?|, ?& chỉ kiểm tra key ở mức top-level của object — không recurse nested. Muốn check nested phải dùng @> hoặc JSONPath (Bước 5).

4

GIN Index Cho JSONB — Default vs jsonb_path_ops

GIN (Generalized Inverted Index) là kiểu index tối ưu cho dữ liệu "có nhiều phần tử nhỏ bên trong" — JSONB key/value, array element, tsvector token. Postgres cho phép 2 operator class khi tạo GIN cho JSONB:

Default — jsonb_ops: index mọi key và mọi value trong JSONB. Support đủ 5 operator (@>, <@, ?, ?|, ?&) nhưng size lớn hơn và write chậm hơn.

CREATE INDEX products_metadata_gin_idx
ON products USING GIN(metadata);
-- Mặc định jsonb_ops — support mọi operator JSONB

jsonb_path_ops: chỉ index path (chuỗi key → giá trị), chỉ support operator @>. Đổi lại size nhỏ hơn ~30% và lookup nhanh hơn.

CREATE INDEX payments_payload_path_idx
ON payments USING GIN(payment_payload jsonb_path_ops);
-- Chỉ support @> nhưng smaller + faster

Trade-off:

Operator class | Operator support              | Size  | Lookup
jsonb_ops      | @>, <@, ?, ?|, ?&             | Large | Slow hơn
jsonb_path_ops | @> chỉ (most common)          | Small | Fast hơn

Lock decision Shop API:

  • payments.payment_payloadjsonb_path_ops. Lý do: query duy nhất là lookup webhook WHERE payment_payload @> '{"payment_intent_id": "pi_..."}' (B71 Stripe handler). Không cần check key existence.
  • products.metadata → default jsonb_ops. Lý do: cần cả @> (filter cụ thể) lẫn ? (check feature flag tồn tại — vd sản phẩm nào có warranty_months để render badge UI).

Khi nào KHÔNG cần GIN: cột JSONB nhỏ + ít row + query rare → seq scan đủ nhanh, index overhead không xứng. Trong Shop API, mọi cột JSONB query thường xuyên đều có GIN — không có exception.

Verify index đã được dùng qua EXPLAIN ANALYZE:

EXPLAIN ANALYZE
SELECT * FROM products WHERE metadata @> '{"color": "black"}';

-- Expected output (rút gọn):
-- Bitmap Heap Scan on products
--   Recheck Cond: (metadata @> '{"color": "black"}'::jsonb)
--   ->  Bitmap Index Scan on products_metadata_gin_idx
--         Index Cond: (metadata @> '{"color": "black"}'::jsonb)
--   Planning Time: 0.2 ms
--   Execution Time: 0.5 ms
5

JSONB Path Expression — Query Sâu

Postgres 12+ hỗ trợ chuẩn SQL/JSON với JSONPath — ngôn ngữ truy vấn nhỏ gọn nested deep, tương tự XPath cho XML.

-- Get value theo path
SELECT jsonb_path_query(payment_payload, '$.card.brand')
FROM payments;
-- "visa"

-- Filter array element theo điều kiện
SELECT jsonb_path_query(metadata, '$.history[*] ? (@.year > 2020)')
FROM products;
-- Trả về element nào trong array history có year > 2020

2 operator path "rút gọn" thường dùng hơn JSONPath đầy đủ:

  • #> — extract value tại path (text array), trả JSONB.
  • #>> — extract value tại path (text array), trả TEXT.
-- Path là array of TEXT — mỗi phần tử là 1 key/index
SELECT metadata #> '{shipping, weight}' FROM products;
-- 1.2     (JSONB)

SELECT metadata #>> '{shipping, weight}' FROM products;
-- 1.2     (TEXT)

-- Truy cập array index trong path
SELECT metadata #>> '{history, 0, year}' FROM products;
-- 2024    (TEXT)

Pattern Shop API: dùng @> cho query đơn giản (đa số case) và #>> cho extract giá trị từ path cố định (vd partial expression index trên metadata->>'warranty_months' ở Bước 7). JSONPath đầy đủ chỉ dùng cho analytics endpoint admin (G14-G15) khi query phức tạp dạng "tìm sản phẩm có history nào year > 2020 và discount > 10%".

6

Array Operator PostgreSQL

Bảng products đã có cột tags TEXT[] với GIN index từ B52. Postgres array có 4 operator chính, parallel với JSONB operator nhưng cú pháp riêng:

= ANY(array) — kiểm tra value có nằm trong array (lock từ B52 + B59):

SELECT * FROM products WHERE 'apple' = ANY(tags);
-- Tags chứa "apple"

@> array contains — array bên trái chứa tất cả phần tử của array bên phải:

SELECT * FROM products WHERE tags @> ARRAY['apple', 'phone'];
-- Tags chứa CẢ "apple" VÀ "phone"

&& array overlap — 2 array có ít nhất 1 phần tử chung:

SELECT * FROM products WHERE tags && ARRAY['apple', 'samsung'];
-- Tags chứa ÍT NHẤT một trong "apple" hoặc "samsung"

|| array concat — nối 2 array hoặc append phần tử:

UPDATE products
SET tags = tags || ARRAY['new-tag']::TEXT[]
WHERE id = 1;

GIN index cho array (lock B52 continued):

CREATE INDEX products_tags_gin_idx
ON products USING GIN(tags);
-- Tăng tốc = ANY, @>, &&

Quy tắc Array vs JSONB Shop API:

Use case                       | Recommend
1D string list (tag, category) | Array TEXT[]
Hierarchical / nested object   | JSONB
Variable schema per row        | JSONB
Cần sort/order theo vị trí     | Array (preserve index)
Full-text search inside        | JSONB + GIN
Audit log raw event payload    | JSONB

Lock decision Shop API:

  • Tags 1D string (products) → TEXT[] array (B52 lock).
  • Metadata variable schema (products) → JSONB (B60 lock).
  • Settings hierarchical (future users.preferences) → JSONB.
  • category_ids primary/secondary order → array BIGINT[] giữ vị trí thứ tự.
7

Migration metadata JSONB + Index

Bảng products đến B59 đã có 9 cột (id, name, slug, price, stock, description, tags, created_at, updated_at). Bước cuối Group 6 thêm cột metadata JSONB cho flexible schema (warranty_months, color, dimensions, custom_attribute_* ...).

Tạo migration mới qua sqlx-cli (lock B58 workflow):

cargo sqlx migrate add \
  --source crates/shop-db/migrations \
  add_products_metadata

# Tạo file:
# crates/shop-db/migrations/20260615150000_add_products_metadata.sql

Edit nội dung migration:

-- File: crates/shop-db/migrations/20260615150000_add_products_metadata.sql

-- 1. Thêm cột metadata JSONB, NOT NULL DEFAULT '{}' (empty object)
ALTER TABLE products
    ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;

-- 2. GIN index default jsonb_ops — support đủ @>, ?, ?|, ?&
CREATE INDEX products_metadata_gin_idx
    ON products USING GIN(metadata);

-- 3. Partial expression index cho query phổ biến warranty_months
--    Chỉ index row có key warranty_months → size nhỏ
CREATE INDEX products_metadata_warranty_idx
    ON products ((metadata->>'warranty_months'))
    WHERE metadata ? 'warranty_months';

Apply migration:

cargo sqlx migrate run --source crates/shop-db/migrations

# Output:
# Applied 20260615150000/migrate add products metadata (12.3ms)

Sau migration, Shop API có 5 migration applied tổng cộng: create_products (B51), add_products_tags (B52), create_orders (B54), create_payments (B54), add_products_metadata (B60).

Cập nhật ProductRow ở DB layer thêm field metadata:

// File: crates/shop-db/src/products.rs
use serde_json::Value as JsonValue;

#[derive(Debug, Clone, sqlx::FromRow)]
pub struct ProductRow {
    pub id: i64,
    pub name: String,
    pub slug: String,
    pub price: rust_decimal::Decimal,
    pub stock: i32,
    pub description: Option<String>,
    pub tags: Vec<String>,
    pub metadata: JsonValue,        // NEW B60 — JSONB → serde_json::Value
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

Cập nhật CreateProductDto wire format cho input client gửi (lock B47 BTreeMap stable):

// File: crates/shop-common/src/dto/product.rs
use std::collections::BTreeMap;
use serde_json::Value;

#[derive(Debug, Clone, Deserialize, Validate)]
pub struct CreateProductDto {
    // ... existing field (name, slug, price, stock, description, tags)
    #[serde(default)]
    pub metadata: BTreeMap<String, Value>,
}

Update mọi SELECT, INSERT hiện tại để thêm cột metadata. Với sqlx::query_as! macro cần regenerate cache:

cargo sqlx prepare --workspace
# Cập nhật .sqlx/ cache cho mọi query_as! đã thay đổi schema
8

Search Metadata + Payment Query Pattern

Extend ProductFilter (B59 lock) thêm 2 filter JSONB:

// File: crates/shop-db/src/products.rs
use serde_json::Value as JsonValue;

#[derive(Debug, Clone)]
pub struct ProductFilter {
    pub name: Option<String>,
    pub tag: Option<String>,
    pub min_price: Option<rust_decimal::Decimal>,
    pub max_price: Option<rust_decimal::Decimal>,
    pub sort: String,
    pub page: u32,
    pub per_page: u32,
    // NEW B60
    pub has_metadata_key: Option<String>,        // ? operator
    pub metadata_contains: Option<JsonValue>,    // @> operator
}

Cập nhật apply_filter (helper reuse SELECT + COUNT, lock B59):

// File: crates/shop-db/src/products.rs
fn apply_filter(
    qb: &mut QueryBuilder<'_, Postgres>,
    filter: &ProductFilter,
) {
    if let Some(name) = &filter.name {
        qb.push(" AND name ILIKE ");
        qb.push_bind(format!("%{}%", name));
    }
    if let Some(min) = filter.min_price {
        qb.push(" AND price >= ").push_bind(min);
    }
    if let Some(max) = filter.max_price {
        qb.push(" AND price <= ").push_bind(max);
    }
    if let Some(tag) = &filter.tag {
        qb.push(" AND ").push_bind(tag.clone()).push(" = ANY(tags)");
    }
    // NEW B60 — JSONB ? operator (key exists top-level)
    if let Some(key) = &filter.has_metadata_key {
        qb.push(" AND metadata ? ").push_bind(key.clone());
    }
    // NEW B60 — JSONB @> operator (contains)
    if let Some(contains) = &filter.metadata_contains {
        qb.push(" AND metadata @> ").push_bind(contains.clone());
    }
}

Extend ProductSearchQuery DTO (lock B59) thêm 2 query param:

// File: crates/shop-common/src/dto/product.rs
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct ProductSearchQuery {
    // ... 7 field existing B59 (name, tag, min_price, max_price, sort, page, per_page)
    #[serde(default)]
    pub has_metadata_key: Option<String>,

    #[serde(default)]
    pub metadata_contains: Option<serde_json::Value>,
}

Verify endpoint mới qua curl:

# 1. Search product có key warranty_months trong metadata
curl 'http://localhost:3000/api/v1/products?has_metadata_key=warranty_months' | jq

# 2. Search product metadata contains specific value
curl 'http://localhost:3000/api/v1/products?metadata_contains=%7B%22color%22%3A%22black%22%7D' | jq
# URL-encoded: {"color":"black"}

# 3. Combo filter — name + metadata
curl 'http://localhost:3000/api/v1/products?name=iphone&has_metadata_key=warranty_months' | jq

Test data nhanh bằng psql để verify GIN index hoạt động:

-- Insert test data
INSERT INTO products (name, slug, price, stock, tags, metadata)
VALUES
  ('iPhone 15', 'iphone-15', 25000000, 10,
   ARRAY['apple', 'phone']::TEXT[],
   '{"warranty_months": 12, "color": "black"}'::jsonb),
  ('Galaxy S24', 'galaxy-s24', 22000000, 5,
   ARRAY['samsung', 'phone']::TEXT[],
   '{"color": "black"}'::jsonb);

-- Extract warranty value qua ->> (text)
SELECT name, metadata->>'warranty_months' AS warranty
FROM products
WHERE metadata ? 'warranty_months';
-- iPhone 15  | 12

-- @> contains pattern
SELECT name FROM products
WHERE metadata @> '{"color": "black"}'::jsonb;
-- iPhone 15
-- Galaxy S24

Pattern thứ 2 — payment query theo payment_intent_id. File crates/shop-db/src/payments.rs đến B54 mới là skeleton, B60 thêm PaymentRow + function lookup chuẩn bị cho B71 Stripe webhook:

// File: crates/shop-db/src/payments.rs
use sqlx::PgPool;
use chrono::{DateTime, Utc};
use serde_json::Value as JsonValue;

#[derive(Debug, Clone, sqlx::FromRow)]
pub struct PaymentRow {
    pub id: i64,
    pub order_id: i64,
    pub payment_type: String,
    pub payment_payload: JsonValue,
    pub status: String,
    pub created_at: DateTime<Utc>,
}

pub async fn find_payments_by_stripe_intent(
    pool: &PgPool,
    intent_id: &str,
) -> Result<Vec<PaymentRow>, sqlx::Error> {
    sqlx::query_as::<_, PaymentRow>(
        r#"
        SELECT id, order_id, payment_type, payment_payload,
               status, created_at
        FROM payments
        WHERE payment_type = 'stripe'
          AND payment_payload @> $1
        "#,
    )
    .bind(serde_json::json!({"payment_intent_id": intent_id}))
    .fetch_all(pool)
    .await
}

Pattern payment_payload @> $1 dùng GIN jsonb_path_ops (lock từ migration create_payments B54). Webhook Stripe nhận event payment_intent.succeeded với id = "pi_3OZx..." sẽ lookup payment record bằng đúng pattern này — KHÔNG cần extract payment_intent_id ra cột riêng, JSONB query đủ nhanh nhờ GIN index.

Suggested commit: B60: products.metadata JSONB column + GIN index + ProductFilter has_metadata_key/metadata_contains + payments find_by_stripe_intent. Nhớ chạy cargo sqlx prepare --workspace trước commit để cập nhật .sqlx/ cache (lock B58).

9

Tổng Kết Group 6 + Roadmap Group 7

Group 6 PostgreSQL + sqlx đã cover trọn vẹn 10 bài (B51-B60):

  • B51 — PostgreSQL 17 Docker Compose + sqlx-cli setup + crate shop-db + migration create_products + wire PgPool vào AppState.
  • B52 — sqlx migration system + clap CLI subcommand db migrate + migration add_products_tags với GIN index array.
  • B53 — Macro query_as! sâu + FromRow derive + nullable Option mapping + override type annotation.
  • B54 — Transaction + savepoint + isolation level + ATOMIC order creation + migration create_orders/create_payments.
  • B55 — Error mapping SQLSTATE → AppError variant + map_db_error helper + retry strategy cho deadlock.
  • B56 — Pool config multi-env + AppConfig + Environment enum + PgPoolOptions tuning chi tiết.
  • B57 — Pool benchmark + Prometheus metrics + /health/live + /health/ready split.
  • B58 — Offline cache .sqlx/ + GitHub Actions CI workflow + integration test với testcontainers.
  • B59 — Dynamic query QueryBuilder + ORDER BY whitelist + search endpoint hoàn chỉnh 6 filter.
  • B60 — JSONB operator + GIN index 2 ops + Array operator + metadata search + payment query pattern.

Foundation ready cho Group 7 và xa hơn:

  • Database schema: 4 bảng (products, orders, order_items, payments) + 9+ index (B-tree + GIN array + GIN JSONB + partial expression).
  • Error handling: 19 AppError variant + SQLSTATE mapping + retry helper.
  • Pool: config env-driven + offline cache + CI workflow ready.
  • Search endpoint: 8 filter (name, tag, min_price, max_price, sort, page, per_page, has_metadata_key, metadata_contains) + apply_filter reuse pattern.
  • 5 migration applied: create_products, add_products_tags, create_orders, create_payments, add_products_metadata.

Group 7 (B61-B75) sẽ cover CRUD HTTP Endpoint Đầy Đủ:

  • B61 — Resource modeling REST + idempotency PUT/DELETE + ETag + pagination cursor.
  • B66 — POST /api/v1/orders với create_order_atomic + HTTP handler đầy đủ.
  • B67-B69 — Cart endpoints (GET cart, POST item, PATCH/DELETE item).
  • B70-B71 — User registration + profile + Stripe webhook integration.
  • B72-B73 — Service layer abstraction + dependency injection pattern.
  • B74 — Domain error handling pattern thay vì trộn HTTP error.
  • B75 — End-to-end CRUD reuse pattern cho mọi resource Shop API.
10

Tổng Kết

  • JSONB binary format thay JSON text — query nhanh, index GIN khả dụng.
  • 6 JSONB operator core: ->, ->>, @>, <@, ?, ?|, ?&.
  • GIN index 2 ops: default jsonb_ops (full operator) vs jsonb_path_ops (chỉ @>, smaller + faster).
  • Decision: payment_payloadjsonb_path_ops; products.metadata → default jsonb_ops.
  • JSONPath SQL/JSON cho query sâu — operator #> (JSONB), #>> (TEXT).
  • 4 array operator: = ANY, @>, &&, || + GIN array index lock B52.
  • Array vs JSONB decision: 1D string → array; variable schema → JSONB; sortable → array; full-text inside → JSONB GIN.
  • Migration 5 mới: add_products_metadata với cột JSONB + 2 index (GIN default + partial expression).
  • ProductFilter extend 2 filter: has_metadata_key (?), metadata_contains (@>).
  • Payment query pattern: payment_payload @> $1 cho lookup theo payment_intent_id (B71 Stripe).
  • HOÀN THÀNH Group 6 (10/10 bài) — foundation ready cho G7 CRUD đầy đủ B61-B75.
  • File path lock: migration 5 add_products_metadata.sql, extend dto/product.rs, extend shop-db/src/products.rs, extend shop-db/src/payments.rs.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. JSONB vs JSON Postgres — khác nhau ra sao về storage và query performance? Khi nào chọn JSON kiểu text?
  2. Phân biệt -> vs ->> vs @> JSONB. Cho ví dụ mỗi operator với payment_payload.
  3. GIN index default jsonb_ops vs jsonb_path_ops — trade-off và khi nào chọn cái nào trong Shop API?
  4. Array @> vs && khác nhau ra sao? Cho ví dụ cụ thể với tags products.
  5. Quy tắc chọn Array vs JSONB Shop API — cho 3 case khác nhau (tags, metadata, hierarchical setting).
Đáp án
  1. JSONB vs JSON Postgres — storage + query performance + khi nào chọn JSON: kiểu JSON Postgres lưu nguyên văn bản gốc (TEXT internal) cùng với khoảng trắng và thứ tự key — INSERT chỉ validate cú pháp xong là lưu, nhanh; nhưng mỗi lần SELECT hoặc query bằng operator phải parse lại từ đầu, chậm + không có index hiệu quả (GIN không cover được). Kiểu JSONB parse JSON 1 lần lúc INSERT thành binary tree (key/value đã decode), lưu dạng nhị phân — INSERT chậm hơn ~5-10% nhưng query nhanh hơn nhiều, hỗ trợ GIN index, comparison giá trị bằng binary compare. JSONB KHÔNG preserve thứ tự key gốc, khoảng trắng, duplicate key (chỉ giữ key cuối nếu input có duplicate). Shop API lock JSONB MANDATORY cho mọi cột flexible schema (metadata, payment_payload, audit log, preferences) — query speed quan trọng hơn insert speed cho workload API. Khi nào dùng JSON text: cần archive nguyên format gốc byte-by-byte (vd webhook signature verify cần raw payload), nhưng case này thường lưu BYTEA raw bytes thay vì JSON text vì JSON text vẫn re-encode khi serialize ra (escape sequence Unicode, normalize số float). Trong Shop API gần như không có case dùng JSON kiểu text — JSONB cho mọi nơi.
  2. -> vs ->> vs @> JSONB + ví dụ với payment_payload: cả 3 đều là operator JSONB nhưng mục đích khác. (a) -> field/index access trả về JSONB — kết quả vẫn là JSONB nên có thể chain tiếp -> hoặc ->>. Ví dụ: SELECT payment_payload->'card' FROM payments → trả {"last4": "4242", "brand": "visa"} (JSONB object); chain tiếp payment_payload->'card'->'brand'"visa" (JSONB string vẫn có dấu nháy). (b) ->> field access trả về TEXT — đã strip dấu nháy, KHÔNG chain tiếp được (TEXT không có operator JSONB). Ví dụ: SELECT payment_payload->>'card_brand' FROM payments → trả visa (TEXT, không có dấu nháy). Dùng khi cần so sánh với chuỗi Rust qua .bind() hoặc cast sang INT: (payment_payload->'card'->>'exp_month')::int. (c) @> contains — JSONB bên trái có chứa cấu trúc bên phải không, trả BOOL. Ví dụ: SELECT * FROM payments WHERE payment_payload @> '{"card_brand": "visa"}'::jsonb match mọi row có key card_brand = "visa"; WHERE payment_payload @> '{"card": {"last4": "4242"}}'::jsonb match nested deep. @> là operator quan trọng nhất cho lookup nested key vì có index GIN tăng tốc (B71 Stripe webhook: payment_payload @> '{"payment_intent_id": "pi_..."}'). Tóm tắt: -> chain JSONB cây sâu, ->> extract value cuối thành TEXT, @> filter row theo cấu trúc match.
  3. GIN index default jsonb_ops vs jsonb_path_ops — trade-off + khi nào chọn Shop API: Postgres cho phép 2 operator class khi tạo GIN cho JSONB. (a) Default jsonb_ops: index mọi key và mọi value trong JSONB → support đủ 5 operator @>, <@, ?, ?|, ?& nhưng size index lớn (~50-100% so với data) + write chậm hơn vì cập nhật nhiều entry. (b) jsonb_path_ops: chỉ index path (chuỗi key → value) → chỉ support operator @> (KHÔNG support ? series) nhưng size nhỏ hơn ~30% + lookup nhanh hơn vì cấu trúc đơn giản hơn. Trade-off ngắn gọn: default = full feature chậm to, path_ops = chỉ @> nhanh nhỏ. Lock decision Shop API: (i) payments.payment_payload dùng jsonb_path_ops vì query duy nhất trong production là lookup webhook Stripe WHERE payment_payload @> '{"payment_intent_id": "..."}' (B71) — không bao giờ check key existence riêng lẻ, chỉ @>. (ii) products.metadata dùng default jsonb_ops vì query có 2 pattern: @> (filter cụ thể: metadata contains color black) + ? (check key existence: sản phẩm nào có warranty để render UI badge). Cần ? nên phải default. Generalize: cột query nhiều pattern → default jsonb_ops; cột chỉ lookup @> nested key cố định → jsonb_path_ops. Verify đúng index dùng qua EXPLAIN ANALYZE thấy "Bitmap Index Scan on ..." là OK.
  4. Array @> vs && khác nhau ra sao + ví dụ với tags products: cả 2 đều là Postgres array operator nhận 2 array bên trái + bên phải, trả BOOL. (a) @> contains — array bên trái chứa TẤT CẢ phần tử của array bên phải (left ⊇ right). Ví dụ tags @> ARRAY['apple', 'phone'] match row có tags chứa CẢ "apple" "phone" (vd ['apple', 'phone', 'iphone'] match; ['apple', 'tablet'] KHÔNG match vì thiếu 'phone'). Logic AND. (b) && overlap — 2 array có ÍT NHẤT 1 phần tử chung (left ∩ right ≠ ∅). Ví dụ tags && ARRAY['apple', 'samsung'] match row có tags chứa "apple" HOẶC "samsung" (vd ['apple', 'phone'] match; ['samsung', 'tablet'] match; ['google', 'pixel'] KHÔNG match). Logic OR. Use case khác nhau: (i) @> dùng cho filter "phải đủ hết các tag" — vd UI cho phép chọn nhiều tag và muốn lọc sản phẩm match đủ; (ii) && dùng cho filter "thuộc về 1 trong số nhóm" — vd category browse "Phone hoặc Tablet" hiển thị mọi sản phẩm thuộc bất kỳ category nào. Ở B59, Shop API dùng = ANY(tags) cho filter 1 tag single value (đơn giản hơn vì client chỉ gửi 1 string); nếu mở rộng cho multi-tag tương lai sẽ dùng @> (AND nhiều tag) hoặc && (OR nhiều tag) tùy semantic UI. Cả 2 operator đều dùng GIN array index B52 nên scan rất nhanh.
  5. Quy tắc Array vs JSONB Shop API — 3 case: dùng matrix quyết định lock từ B60. (a) Case tags products (tags TEXT[]) — list 1D chuỗi đơn giản, mỗi phần tử cùng kiểu (string), không có nested object, cần sort/order theo vị trí preserve (admin nhập tags theo thứ tự ưu tiên), cần = ANY + @> + && operator hiệu quả → chọn Array TEXT[] với GIN index lock B52. JSONB array overkill cho case này (overhead binary tree, không tận dụng được path_ops). (b) Case metadata products (metadata JSONB) — variable schema mỗi row có set key khác nhau (warranty_months, color, dimensions, custom_attribute_*), nested object có thể (vd shipping: {weight, length, width}), không cần preserve order key, cần query @> (contains nested) + ? (key exists) — quy tắc kinh điển variable schema dùng JSONB → chọn JSONB với GIN default jsonb_ops support cả @> + ?. Array TEXT[] không phù hợp vì không thể nested object. (c) Case hierarchical settings users.preferences (tương lai G18) — tree nested deep (notification.email.order_confirm, notification.email.shipping_update, ui.theme, ui.language, ...), schema cố định partial nhưng có user extend custom field, cần extract giá trị từ path cố định qua #>>chọn JSONB với GIN default jsonb_ops. Array dù multi-dimension TEXT[][] không model được tree, chỉ matrix vuông. Tổng matrix quyết định: 1D string list + cần sort + operator = ANY/@>/&& → Array; variable schema + nested object + cần @>/? → JSONB; hierarchical tree → JSONB; sortable theo vị trí cố định → Array; full-text search trong field → JSONB + GIN; raw event payload audit → JSONB; ID list FK 1D → Array (vd category_ids BIGINT[] giữ thứ tự primary/secondary lock B47).
12

Bài Tiếp Theo

— mở Group 7 CRUD đầy đủ: resource modeling REST, idempotency cho PUT/DELETE, ETag header, pagination strategy (offset vs cursor), versioning chiến lược, áp Shop API full CRUD pattern cho products/orders/users/carts.