Mục lục
- Mục Tiêu Bài Học
- Cơ Chế Compile-Time Check
query!Vsquery_as!— Khác Biệt Output TypeFromRowTrait — Derive Vs Manual Impl- Nullable Column →
Option<T>Mapping - Override Type Checking Với
as "name: TypeOverride" - Custom Type Mapping — Decimal, UUID, JSONB, chrono
- Full CRUD
products— Thêm GET/PATCH/DELETE Handler .sqlx/Offline Cache Cho CI/CD- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu cơ chế compile-time check của
query!/query_as!(kết nối DB lúc compile). - Phân biệt
FromRowderive vs manual impl. - Xử lý nullable column qua
Option<T>mapping. - Override type checking với syntax
as "alias: Type"(3 modifier:,!:,?:). - Custom type mapping Decimal, UUID, JSONB, chrono qua feature flag.
- Implement full CRUD products Shop API — GET single, PATCH update, DELETE.
- Hiểu
.sqlx/offline cache cho CI/CD build không cần DB.
Cơ Chế Compile-Time Check
Macro sqlx::query! và sqlx::query_as! chạy logic verify SQL ngay lúc cargo build. Đây là điểm khác biệt cốt lõi sqlx so với mọi crate Postgres khác (tokio-postgres, diesel runtime-check, sea-orm).
Workflow 5 bước macro thực hiện lúc compile:
- Đọc SQL string literal trong macro invocation.
- Connect tới database qua
DATABASE_URLenvironment variable (hoặc đọc offline cache.sqlx/— Bước 9). - Send
PREPAREstatement → Postgres parse SQL + build query plan. - Lấy metadata: parameter type (
$1,$2, ...) + result column type + nullability từ schema. - Generate Rust code với type chính xác — bind parameter, decode column, build struct.
Yêu cầu compile time:
DATABASE_URLenv var SET (lấy từ.envhoặc shell).- DB running + accessible từ host build.
- Schema match — table + column tồn tại đúng tên + đúng type.
Test thử rename column tags thành tag_list qua psql mà KHÔNG sửa SQL trong macro:
error: error returned from database: column "tags" does not exist
--> crates/shop-db/src/products.rs:25:9
|
25 | sqlx::query_as!(ProductRow, "SELECT id, name, slug, ... tags ...");
Bug schema mismatch dừng ngay ở cargo check — KHÔNG đến production. Đây là pros lớn nhất của sqlx so với runtime ORM (sea-orm, diesel runtime mode) — typo column hoặc thiếu column lộ ngay developer machine, không cần test hit endpoint mới biết.
Cons: CI/CD build phải có DB live → chậm + setup phức tạp. Solution là offline cache .sqlx/ (Bước 9) — generate cache cục bộ, commit vào git, CI đọc cache thay connect DB.
Pitfall: schema thay đổi sau khi cache → fail compile vì cache outdated. Khắc phục: chạy cargo sqlx prepare mỗi khi migration mới + commit cùng PR với migration .sql — pattern lock B52 continued.
query! Vs query_as! — Khác Biệt Output Type
Cả hai macro cùng compile-time check, khác nhau ở output type sau khi fetch.
sqlx::query! trả anonymous record — struct ẩn macro tự generate, field name match column name SELECT:
let row = sqlx::query!(
"SELECT id, name FROM products WHERE slug = $1",
"iphone-15"
)
.fetch_one(&pool)
.await?;
println!("id={}, name={}", row.id, row.name);
// row có type ẩn: { id: i64, name: String }
Anonymous record phù hợp ad-hoc query không cần định nghĩa struct riêng — count, exists, single value, kiểm tra điều kiện.
sqlx::query_as! bind kết quả vào struct đã define sẵn:
pub struct ProductRow {
pub id: i64,
pub name: String,
pub slug: String,
pub price: Decimal,
pub stock: i32,
pub description: Option<String>,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
let product: ProductRow = sqlx::query_as!(
ProductRow,
"SELECT id, name, slug, price, stock, description, tags, created_at, updated_at
FROM products WHERE slug = $1",
"iphone-15"
)
.fetch_one(&pool)
.await?;
Struct phải có field name match column name SELECT (kể cả thứ tự — sqlx position-based). Field nào không match → compile error rõ ràng.
Khi nào dùng nào:
query!— ad-hoc query 1-3 column (count, exists, single value, simple aggregation). Không cần define struct riêng.query_as!— load entire row vào struct đã có (CRUD chính). Reuse struct cross handler.
Lock pattern Shop API: query_as! cho mọi CRUD trên entity (products, orders, users); query! cho count + exists check + scalar aggregation. Tránh tự tạo nhiều struct nhỏ chỉ để dùng 1 lần.
FromRow Trait — Derive Vs Manual Impl
Bản chất việc map row DB sang struct Rust thực hiện qua trait sqlx::FromRow. Khi dùng macro query_as!, sqlx tự generate impl FromRow ẩn lúc compile-time check.
Có 2 cách dùng FromRow không qua macro — phù hợp cho dynamic query runtime hoặc transform custom.
Cách 1 — Derive macro: #[derive(sqlx::FromRow)] auto map column name → field name:
#[derive(sqlx::FromRow)]
pub struct ProductRow {
pub id: i64,
pub name: String,
pub slug: String,
// ... field name == column name
}
// Dùng với query_as() runtime (không phải macro!)
let row: ProductRow = sqlx::query_as("SELECT * FROM products WHERE slug = $1")
.bind("iphone-15")
.fetch_one(&pool)
.await?;
Lưu ý query_as() (function thường) khác query_as! (macro). Function runtime KHÔNG compile-time check — bug column name lộ lúc runtime test endpoint. Đổi lại linh hoạt: SQL có thể build dynamic từ string (vd format!() với WHERE clause variable), phù hợp search filter user input — chi tiết B59.
Cách 2 — Manual impl: control mapping tùy ý:
impl<'r> sqlx::FromRow<'r, sqlx::postgres::PgRow> for ProductRow {
fn from_row(row: &'r sqlx::postgres::PgRow) -> Result<Self, sqlx::Error> {
use sqlx::Row;
Ok(Self {
id: row.try_get("id")?,
name: row.try_get("name")?,
slug: row.try_get("slug")?,
// ... custom transform tại đây
// ví dụ parse JSONB string thành domain enum
// ví dụ join 2 column thành 1 field
})
}
}
Khi nào dùng nào:
query_as!macro — 95% case Shop API (compile-time check + auto FromRow).#[derive(FromRow)]+query_as()runtime — dynamic query với SQL build động, search filter, sort multi-column user input (B59 deep).- Manual impl — custom column transform (rare). Ví dụ parse JSONB blob thành Rust enum custom logic, join 2 column thành 1 field domain, hoặc validate column value lúc decode.
Lock pattern Shop API: macro cho static SQL (mọi CRUD), derive cho runtime dynamic (search/filter B59), manual impl chỉ khi macro + derive đều không đáp ứng.
Nullable Column → Option<T> Mapping
Postgres column có NULL mapping sang Rust Option<T>. Macro query_as! tự detect nullability từ schema metadata:
-- File: crates/shop-db/migrations/20260615120000_create_products.sql (B51)
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL, -- NOT NULL → Rust String
slug TEXT NOT NULL UNIQUE,
description TEXT, -- nullable → Rust Option
...
);
pub struct ProductRow {
pub name: String, // NOT NULL → String
pub description: Option<String>, // nullable → Option
// ...
}
Macro check khớp: struct field type mismatch nullability → compile error rõ ràng. Ví dụ khai báo description: String (không Option) trong khi schema cho phép NULL → compile error:
error: mismatched types
nullable column `description` should be Option<String>, found String
Pitfall COALESCE: SQL hint dạng COALESCE confuses macro nullability inference. Postgres execution plan không expose nullability cho expression — macro mặc định coi nullable:
SELECT id, COALESCE(description, '') AS description FROM products
Sqlx không suy được column description sau COALESCE chắc chắn không bao giờ NULL (mặc dù logic SQL guarantee). Compile error vì struct khai báo description: String, expression trả Option<String>.
Solution: dùng override syntax as "name!: Type" force NOT NULL (Bước 6).
Lock pattern Shop API: viết SQL clean — column nullable thì NULL truyền thẳng qua wire, default value xử lý ở app level (vd description: row.description.unwrap_or_default() hoặc giữ Option mọi tầng cho semantic rõ "không có description" vs "description rỗng").
Override Type Checking Với as "name: TypeOverride"
Macro query_as! cho phép force type + nullability cho 1 column qua syntax đặc biệt trong SQL string. 3 modifier:
name: Type— override type (giữ nullability default — thườngOption<T>cho expression).name!: Type— force NOT NULL (xóaOptionwrap).name?: Type— force nullable (thêmOptionwrap).
Use case 1 — fix COALESCE pitfall ở Bước 5:
SELECT id, COALESCE(description, '') AS "description!: String" FROM products
Force NOT NULL — sqlx tin lập trình viên rằng expression không bao giờ NULL. Struct description: String compile pass.
Use case 2 — JSONB column với typed struct:
#[derive(serde::Serialize, serde::Deserialize)]
pub struct OrderMetadata {
pub campaign_id: Option<String>,
pub coupon: Option<String>,
}
sqlx::query_as!(
OrderRow,
r#"SELECT id, metadata as "metadata: sqlx::types::Json<OrderMetadata>"
FROM orders WHERE id = $1"#,
order_id
)
sqlx::types::Json<T> wrap deserialize JSONB cell thành struct domain qua serde_json tự động.
Use case 3 — Postgres custom enum:
SELECT id, status as "status: OrderStatus" FROM orders
Force decode column status (Postgres ENUM type) thành Rust enum OrderStatus đã define (cần #[derive(sqlx::Type)] — Bước 7).
Use case 4 — Decimal precision:
SELECT id, price as "price: rust_decimal::Decimal" FROM products
Force decode NUMERIC column thành rust_decimal::Decimal (mặc định sqlx có thể chọn bigdecimal tùy feature flag — ép cho rõ).
Lock pattern Shop API: dùng override syntax cho JSONB typed (preview B72 metadata payment + B62 order metadata), custom Postgres enum (preview B72 — Shop API quyết định SKIP enum native, dùng TEXT discriminator). Static CRUD products ở B53 KHÔNG cần override vì 9 column đều primitive type sqlx auto-cast.
Custom Type Mapping — Decimal, UUID, JSONB, chrono
Sqlx hỗ trợ custom type Rust qua feature flag Cargo.toml. Workspace Shop API đã enable từ B51:
# File: Cargo.toml (workspace root, lock B51)
[workspace.dependencies]
sqlx = { version = "0.8", features = [
"postgres",
"runtime-tokio",
"tls-rustls",
"macros",
"chrono", # DateTime ↔ TIMESTAMPTZ
"uuid", # uuid::Uuid ↔ UUID
"rust_decimal", # Decimal ↔ NUMERIC
# "json", # serde_json::Value ↔ JSONB (sẽ enable B62)
] }
Bảng type mapping Postgres ↔ Rust với feature tương ứng:
Postgres | Rust | sqlx feature
--------------------+----------------------------+-----------------
BIGINT (i64) | i64 | core
INT (i32) | i32 | core
TEXT / VARCHAR | String | core
BOOLEAN | bool | core
NUMERIC | rust_decimal::Decimal | rust_decimal
UUID | uuid::Uuid | uuid
TIMESTAMPTZ | chrono::DateTime<Utc> | chrono
DATE | chrono::NaiveDate | chrono
JSONB | serde_json::Value | json
JSONB (typed) | sqlx::types::Json<T> | json
TEXT[] | Vec<String> | core
BYTEA | Vec<u8> | core
ENUM (Postgres) | Rust enum + sqlx::Type | macros
Postgres ENUM mapping qua derive sqlx::Type:
#[derive(sqlx::Type, Debug, Clone)]
#[sqlx(type_name = "order_status_enum", rename_all = "snake_case")]
pub enum OrderStatus {
Pending,
Paid,
Shipped,
Delivered,
Cancelled,
}
CREATE TYPE order_status_enum AS ENUM (
'pending', 'paid', 'shipped', 'delivered', 'cancelled'
);
Lock decision Shop API: SKIP Postgres native ENUM. Dùng TEXT discriminator + JSONB payload pattern (lock B43 enum tagged) cho mọi domain enum (OrderStatus, PaymentMethod, NotificationType). Lý do:
- Thêm value mới cho native ENUM cần
ALTER TYPE ADD VALUE— DDL không support transaction (-- sqlx-no-transactionlock B52) + Postgres < 12 cần restart connection để thấy value mới. - TEXT discriminator linh hoạt hơn — thêm/xóa value qua app code deploy (KHÔNG migration), validate qua serde tagged enum B43.
- Industry pattern Stripe/Shopify/GitHub đều dùng TEXT discriminator cho domain enum.
Full CRUD products — Thêm GET/PATCH/DELETE Handler
B52 đã wire list_products + create_product dùng shop_db::products::*. B53 hoàn thiện CRUD với 3 endpoint còn lại: GET single, PATCH update, DELETE.
Cập nhật crates/shop-db/src/products.rs — thêm find_by_slug (đã có ở B51 skeleton), update_product, delete_product:
// File: crates/shop-db/src/products.rs (extend B53)
use sqlx::PgPool;
use rust_decimal::Decimal;
use chrono::{DateTime, Utc};
// ... ProductRow + list_products + count_products + create_product (B52) giữ nguyên
pub async fn find_by_slug(
pool: &PgPool,
slug: &str,
) -> Result<Option<ProductRow>, sqlx::Error> {
sqlx::query_as!(
ProductRow,
r#"
SELECT id, name, slug, price, stock, description, tags, created_at, updated_at
FROM products
WHERE slug = $1
"#,
slug
)
.fetch_optional(pool)
.await
}
pub async fn update_product(
pool: &PgPool,
slug: &str,
name: Option<&str>,
description: Option<Option<&str>>, // double-Option PATCH (B42)
stock: Option<i32>,
) -> Result<ProductRow, sqlx::Error> {
sqlx::query_as!(
ProductRow,
r#"
UPDATE products SET
name = COALESCE($2, name),
description = CASE
WHEN $3::boolean THEN $4
ELSE description
END,
stock = COALESCE($5, stock),
updated_at = NOW()
WHERE slug = $1
RETURNING id, name, slug, price, stock, description, tags, created_at, updated_at
"#,
slug,
name,
description.is_some(),
description.flatten(),
stock
)
.fetch_one(pool)
.await
}
pub async fn delete_product(
pool: &PgPool,
slug: &str,
) -> Result<u64, sqlx::Error> {
let result = sqlx::query!("DELETE FROM products WHERE slug = $1", slug)
.execute(pool)
.await?;
Ok(result.rows_affected())
}
3 điểm kỹ thuật quan trọng:
find_by_slugdùngfetch_optionaltrảOption<ProductRow>— handler matchNoneđể trả 404 với message custom (vd"product 'iphone-99' not found") thay default mappingRowNotFound → NotFoundquafetch_one(B52 lock).update_productvới double-Option PATCH — typeOption<Option<&str>>phân biệt 3 trạng thái field (lock B42):None= không update,Some(None)= set NULL explicit,Some(Some(value))= set value. SQL patternCASE WHEN $boolean THEN $value ELSE column ENDimplement đúng semantic — boolean flagdescription.is_some()báo có thay đổi description không, valuedescription.flatten()làOption<&str>bind thẳng (NULL khiSome(None)).delete_productdùngquery!macro (không cần struct) +executetrảPgQueryResult+rows_affected()trảu64. Handler check 0 = không tồn tại → 404.
Cập nhật crates/shop-api/src/routes/products.rs — thêm 3 handler + update route function:
// File: crates/shop-api/src/routes/products.rs (extend B53)
use shop_common::dto::UpdateProductDto;
use crate::responses::NoContent;
pub async fn get_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
) -> Result<Json<ProductResponseDto>, AppError> {
let row = db::find_by_slug(&state.db, &slug)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}' not found", slug)))?;
Ok(Json(ProductResponseDto::from(row)))
}
pub async fn update_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
ValidatedJson(dto): ValidatedJson<UpdateProductDto>,
) -> Result<Json<ProductResponseDto>, AppError> {
let row = db::update_product(
&state.db,
&slug,
dto.name.as_deref(),
dto.description.as_ref().map(|opt| opt.as_deref()),
dto.stock.map(|s| s as i32),
)
.await?;
Ok(Json(ProductResponseDto::from(row)))
}
pub async fn delete_product(
State(state): State<AppState>,
AppPath(slug): AppPath<String>,
) -> Result<NoContent, AppError> {
let affected = db::delete_product(&state.db, &slug).await?;
if affected == 0 {
return Err(AppError::NotFound(format!("product '{}' not found", slug)));
}
Ok(NoContent)
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/products",
get(list_products).post(create_product))
.route("/products/{slug}",
get(get_product)
.patch(update_product)
.delete(delete_product))
.route("/products/export.ndjson", get(export_products_ndjson))
.route("/products/import.ndjson",
post(import_products_ndjson)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)))
}
Route /products/{slug} chain 3 method qua MethodRouter (lock B21) — gọn hơn 3 dòng .route() riêng. NoContent helper response 204 (lock B40) — handler trả thẳng NoContent, không cần build StatusCode::NO_CONTENT + empty body thủ công.
Verify end-to-end:
# GET single
curl http://localhost:3000/api/v1/products/iphone-15
# 200 OK { "id": 1, "name": "iPhone 15", "slug": "iphone-15", ... }
# GET không tồn tại
curl -i http://localhost:3000/api/v1/products/iphone-99
# 404 Not Found
# { "error": "not found: product 'iphone-99' not found", "code": "NOT_FOUND", "request_id": "..." }
# PATCH update stock
curl -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
-H 'Content-Type: application/json' \
-d '{"stock": 5}'
# 200 OK { ..., "stock": 5, ... }
# PATCH description = null (xóa explicit)
curl -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
-H 'Content-Type: application/json' \
-d '{"description": null}'
# 200 OK { ..., "description": null, ... }
# PATCH bỏ qua field description (giữ nguyên)
curl -X PATCH http://localhost:3000/api/v1/products/iphone-15 \
-H 'Content-Type: application/json' \
-d '{"stock": 10}'
# 200 OK — description không đổi
# DELETE
curl -i -X DELETE http://localhost:3000/api/v1/products/iphone-15
# 204 No Content
# DELETE lại → 404
curl -i -X DELETE http://localhost:3000/api/v1/products/iphone-15
# 404 Not Found
Suggested commit: B53: full CRUD products + find_by_slug/update_product/delete_product + 3 handler GET/PATCH/DELETE + double-Option PATCH SQL CASE WHEN.
.sqlx/ Offline Cache Cho CI/CD
CI/CD container build trên GitHub Actions, Railway, fly.io không có Postgres sống. Macro query!/query_as! fail compile vì không connect được DB.
Solution: generate offline cache cục bộ, commit vào git. CI build đọc cache thay connect DB:
# Local dev (DB live qua docker compose up)
cargo sqlx prepare --workspace --database-url $DATABASE_URL
# query data written to .sqlx in the workspace root
Output là folder .sqlx/ chứa nhiều file JSON, mỗi file đại diện 1 macro invocation:
.sqlx/
├── query-a3f8e9b2c1d4....json
├── query-b7c2d4e8f1a3....json
└── query-c1d4e8f2a5b9....json
Mỗi file chứa metadata: SQL source, parameter type, result column type + nullability — đủ cho macro generate code mà không cần connect DB.
Commit .sqlx/ folder vào git như source code. CI/CD enable offline mode qua env flag:
# File: .github/workflows/ci.yml (preview)
name: CI
on: [push, pull_request]
env:
SQLX_OFFLINE: true # macro đọc .sqlx/ thay connect DB
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --workspace
- run: cargo test --workspace
Workflow team dev:
- Local:
docker compose up -d postgres+sqlx migrate runapply migration mới. - Local: viết/sửa query macro,
cargo checkverify compile pass với DB live. - Local:
cargo sqlx prepare --workspaceregenerate.sqlx/cache. - Commit migration
.sql+ code Rust +.sqlx/trong cùng 1 PR. - PR review: reviewer kiểm
.sqlx/đã update kèm migration mới (CI fail nếu cache outdated). - CI build với
SQLX_OFFLINE=true— không cần DB sống.
Pitfall: dev quên chạy cargo sqlx prepare sau khi sửa SQL → CI fail vì cache outdated. Khắc phục qua pre-commit hook hoặc Makefile rule:
# Makefile helper
prepare:
cargo sqlx prepare --workspace -- --tests
# Hoặc bash script kiểm trước commit
git diff --cached --name-only | grep -q 'migrations/.*\.sql$' && \
cargo sqlx prepare --workspace
Pattern lock Shop API: commit .sqlx/ folder vào git (KHÔNG gitignore), CI build với SQLX_OFFLINE=true, dev local mặc định build với DB live (cache hint nếu DB không sống). Workflow team mandatory từ B53 onward — mọi PR thay đổi SQL macro phải kèm .sqlx/ update.
Tổng Kết
- Compile-time check cơ chế — macro connect DB lúc
cargo build, generate code type-safe; schema mismatch ERROR ởcargo check. query!vsquery_as!— anonymous record (ad-hoc) vs struct mapping (CRUD chính).FromRowderive với#[derive(sqlx::FromRow)]cho dynamic queryquery_as()runtime.- Manual
FromRowimpl cho custom column transform (rare — JSONB unmarshal, join field). - Nullable column auto detect —
NOT NULL→T, nullable →Option<T>; pitfall COALESCE confuses macro. - Override syntax 3 modifier —
name: Type,name!: Typeforce NOT NULL,name?: Typeforce nullable. - Custom type mapping qua feature flag — chrono (DateTime), uuid (Uuid), rust_decimal (Decimal), json (sqlx::types::Json).
- Postgres ENUM map qua
#[derive(sqlx::Type)]— Shop API SKIP, dùng TEXT discriminator + JSONB payload (B43 lock). - Full CRUD pattern products: list + create (B52) + get + update + delete (B53) — 5 endpoint resource cơ bản.
- 3 helper response:
Json<T>200,Created<T>201,NoContent204 (B40 lock). - PATCH với double-Option — SQL pattern
CASE WHEN $boolean THEN $value ELSE column ENDimplement semantic 3 trạng thái field. .sqlx/offline cache cho CI/CD — commit vào git,cargo sqlx prepare --workspaceregenerate mỗi migration mới.SQLX_OFFLINE=trueenv flag enable offline mode CI build không cần DB.- File path lock: extend
crates/shop-db/src/products.rs+crates/shop-api/src/routes/products.rs.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Compile-time check của
query!cần điều kiện gì lúc compile? Trade-off với CI/CD ra sao? query!vsquery_as!khác output type ra sao? Khi nào chọn macro nào?as "name!: String"modifier có tác dụng gì? Cho ví dụ vớiCOALESCE.- PATCH với double-Option
Option<Option<T>>map sang SQL UPDATE ra sao? Trình bàyCASE WHENpattern. .sqlx/offline cache giải quyết vấn đề gì? Workflow team dev có những bước nào?
Đáp án
- Compile-time check yêu cầu + trade-off CI/CD: Macro
query!+query_as!chạy 5 bước lúccargo build— (a) đọc SQL string literal từ macro invocation, (b) connect tới database quaDATABASE_URLenv var (hoặc đọc offline cache.sqlx/nếu setSQLX_OFFLINE=true), (c) sendPREPAREstatement → Postgres parse SQL + build query plan, (d) lấy metadata parameter type + result column type + nullability từ schema, (e) generate Rust code type-safe bind parameter + decode column. Yêu cầu compile:DATABASE_URLenv var SET, DB running + accessible từ host build, schema match (table + column tồn tại đúng tên + type). Pros: bug schema mismatch (typo column, sai type, thiếu column) → ERROR ngay ởcargo checkdeveloper machine, KHÔNG đến production; type-safe parameter binding compile-time validate; refactor schema lan tỏa ngay (đổi column name → compile fail mọi macro reference). Cons: CI/CD container cần DB sống → chậm setup + phức tạp (spin Postgres service container GitHub Actions, port mapping, healthcheck wait). Solution: offline cache.sqlx/generate cục bộ quacargo sqlx prepare --workspace --database-url $DATABASE_URL, commit vào git. CI build vớiSQLX_OFFLINE=trueenv flag — macro đọc cache JSON thay connect DB. Pattern lock Shop API: dev local build với DB live (cache hint), CI build offline mode mandatory; mọi PR sửa SQL phải kèm.sqlx/update cùng commit. query!vsquery_as!+ khi nào chọn cái nào:query!trả anonymous record — struct ẩn macro tự generate, field name match column name SELECT, không cần define struct riêng. Vdlet row = sqlx::query!("SELECT id, name FROM products WHERE slug = $1", "iphone-15").fetch_one(&pool).await?; println!("{}: {}", row.id, row.name);— type ẩn{ id: i64, name: String }. Phù hợp ad-hoc query 1-3 column, scalar aggregation, simple condition check.query_as!bind kết quả vào struct user-defined sẵn — struct field name + type phải match column SELECT (kể cả thứ tự — sqlx position-based). Vdlet product: ProductRow = sqlx::query_as!(ProductRow, "SELECT id, name, slug, price, ... FROM products WHERE slug = $1", "iphone-15").fetch_one(&pool).await?;. Phù hợp load entire row vào struct reuse cross handler. Khi nào dùng nào:query!cho count (SELECT COUNT(*) FROM products), exists check (SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)), scalar single value (SELECT total_orders FROM users WHERE id = $1), DELETE/UPDATE không cần RETURNING;query_as!cho CRUD load entire row (list + create + get + update + delete RETURNING) — struct ProductRow/UserRow/OrderRow reuse mọi handler cùng resource. Lock pattern Shop API:query_as!cho mọi CRUD entity,query!cho count/exists/scalar aggregation. Tránh tự tạo nhiều struct nhỏ chỉ dùng 1 lần — anonymous record gọn hơn.as "name!: String"modifier + ví dụ COALESCE: Macroquery_as!cho phép force type + nullability cho 1 column qua syntax đặc biệt trong SQL string. 3 modifier:as "name: Type"override type (giữ nullability default — thườngOption<T>cho expression),as "name!: Type"force NOT NULL (xóaOptionwrap),as "name?: Type"force nullable (thêmOptionwrap). Use caseCOALESCE: SQLCOALESCE(expr, fallback)trả non-NULL nếuexprNULL thì dùngfallback— logic SQL guarantee không bao giờ NULL, NHƯNG Postgres execution plan không expose nullability cho expression nói chung; macro mặc định coi nullable. VdSELECT id, COALESCE(description, '') AS description FROM productsvới structdescription: String(khôngOption) → compile error vì macro suy columndescriptionlàOption<String>. Fix: dùngSELECT id, COALESCE(description, '') AS "description!: String" FROM products— force NOT NULL, sqlx tin lập trình viên expression không bao giờ NULL. Structdescription: Stringcompile pass. Use case khác:SELECT id, metadata as "metadata: sqlx::types::Json<OrderMetadata>" FROM ordersdeserialize JSONB cell thành struct domain qua serde_json;SELECT id, status as "status: OrderStatus" FROM ordersdecode Postgres ENUM thành Rust enum đã#[derive(sqlx::Type)];SELECT id, price as "price: rust_decimal::Decimal" FROM productsép type cho NUMERIC tránh sqlx chọn nhầmbigdecimaltùy feature flag. Lock pattern Shop API: dùng override cho JSONB typed (preview B72), custom Postgres enum (Shop API SKIP — dùng TEXT discriminator); static CRUD không cần override vì column toàn primitive auto-cast.- PATCH double-Option + SQL CASE WHEN pattern: Type
Option<Option<T>>phân biệt 3 trạng thái field PATCH (lock B42):None= client không gửi field (giữ nguyên DB value),Some(None)= client gửinullexplicit (set NULL trong DB — xóa value),Some(Some(value))= client gửi value mới (update). Trong DTO Rust:pub description: Option<Option<String>>deserialize qua helperdeserialize_optional_field(B42 lock) phân biệt missing vs null. SQL pattern:UPDATE products SET description = CASE WHEN $boolean THEN $value ELSE description END— boolean flag báo có thay đổi description không, nếu true thì set value (có thể NULL), nếu false giữ nguyên column. Code Rust bind 2 parameter:description.is_some()(boolean — true khiSome(...)) +description.flatten()(Option<&str>— bind NULL khiSome(None), bind value khiSome(Some(v)), bind NULL khiNonenhưng boolean = false nên SQL không dùng giá trị này). Vd:sqlx::query_as!(ProductRow, "UPDATE products SET description = CASE WHEN $3::boolean THEN $4 ELSE description END WHERE slug = $1 RETURNING ...", slug, name, description.is_some(), description.flatten(), stock). Alternative pattern: COALESCE chỉ cho field non-null (vdname = COALESCE($2, name)) — không phân biệt được "client gửi null explicit" vs "client không gửi" nên KHÔNG dùng cho nullable field PATCH; chỉ dùng cho field NOT NULL. Lock pattern Shop API: COALESCE cho NOT NULL field (name,stock), CASE WHEN cho nullable field (description,last_login_at); double-Option DTO mandatory cho mọi nullable field PATCH endpoint. .sqlx/offline cache vấn đề giải quyết + workflow team dev: Vấn đề: macroquery!/query_as!compile-time check yêu cầu DB sống lúccargo build— CI/CD container (GitHub Actions, Railway, fly.io) không có Postgres sẵn → build fail. Workaround spin Postgres service container CI: chậm (startup 10-20s), phức tạp setup (port mapping, healthcheck wait, migration apply trước), tốn resource CI minute pricing. Solution offline cache: generate metadata cache cục bộ quacargo sqlx prepare --workspace --database-url $DATABASE_URL— sqlx connect DB local, chạy mọi macro invocation, dump metadata (SQL source, param type, result column type + nullability) ra folder.sqlx/dạng file JSON (query-<hash>.jsonmỗi macro 1 file, hash từ SQL content + struct binding). Commit.sqlx/vào git như source code. CI build set env flagSQLX_OFFLINE=true→ macro đọc cache JSON thay connect DB → compile pass không cần Postgres. Workflow team dev: (a) Localdocker compose up -d postgres+sqlx migrate run --source crates/shop-db/migrationsapply migration mới; (b) Viết/sửa query macro trong code Rust, chạycargo checkverify compile pass với DB live (catch typo column, sai type early); (c) Chạycargo sqlx prepare --workspaceregenerate.sqlx/cache cho mọi macro invocation workspace; (d) Commit kèm trong PR: migration.sql+ code Rust +.sqlx/folder; (e) PR review reviewer kiểm.sqlx/đã update kèm migration mới (CI fail nếu cache outdated — macro hash khác cache hash); (f) CI build vớiSQLX_OFFLINE=trueenv flag không cần DB sống. Pitfall: dev quên chạycargo sqlx preparesau khi sửa SQL → CI fail vì cache hash mismatch. Khắc phục qua pre-commit hook git hoặc Makefile rulemake preparephải chạy trước commit migration. Pattern lock Shop API: commit.sqlx/mandatory (KHÔNG gitignore), CI build offline mode mandatory, dev local mặc định build với DB live; workflow team document trong README onboarding.
Bài Tiếp Theo
Bài 54: sqlx Transaction + Savepoint — chi tiết transaction sqlx: pool.begin() context manager, COMMIT/ROLLBACK explicit + auto-rollback on drop, nested savepoint, async drop pitfall, isolation level (READ COMMITTED default vs SERIALIZABLE), áp ATOMIC order creation pattern (insert order + update stock + insert payment trong 1 transaction — partial fail rollback toàn bộ).
