Trang chủ

SQL được kiểm tra lúc biên dịch với sqlx

Phần lớn thư viện cơ sở dữ liệu chọn giữa viết SQL (nhanh, không an toàn) và một ORM (an toàn, và giờ bạn đang học một DSL truy vấn thay vì học SQL). sqlx làm một thứ khác: bạn viết SQL, và nó được đối chiếu với schema thật của bạn lúc biên dịch.

let user = sqlx::query_as!(
    User,
    "SELECT id, name, email, created_at FROM users WHERE id = $1",
    user_id
)
.fetch_one(&pool)
.await?;

Nếu bảng users không có cột email thì đó là một lỗi biên dịch. Nếu id là uuid còn user_id là i64, lỗi biên dịch. Nếu User có một trường mà truy vấn không chọn ra, lỗi biên dịch.

Nó hoạt động thế nào

Các macro query! kết nối tới một cơ sở dữ liệu lúc biên dịch, dùng DATABASE_URL, rồi yêu cầu nó mô tả truy vấn. Postgres sẽ nói cho bạn biết các cột kết quả, kiểu của chúng và việc chúng có cho phép null hay không, mà không thực thi gì cả.

Macro sau đó sinh ra một struct khớp với hình dạng ấy, hoặc kiểm chứng struct của bạn với nó. Chính hiểu biết của cơ sở dữ liệu về schema là nguồn sự thật, và đó là lý do cách này bắt được những thứ mà một phép ánh xạ viết tay thì không.

Nó bắt được gì mà test thì không

Tính cho phép null. Đây là thứ khiến tôi ấn tượng. Postgres biết email là NOT NULL còn deleted_at thì không, nên sqlx sinh ra String cho cái này và Option<String> cho cái kia. Một LEFT JOIN khiến các cột trở nên cho phép null, và các kiểu được sinh ra đi theo — nên một truy vấn có join giờ tạo ra các trường Option<T> và trình biên dịch buộc bạn xử lý trường hợp vắng mặt.

Lệch kiểu sau một lần migration. Đổi id từ serial sang uuid và mọi truy vấn dùng nó đều biên dịch hỏng, liệt kê ra từng cái. Không có điều này thì cuộc migration ấy là một lỗi lúc chạy được tìm thấy trong môi trường thật.

Gõ sai tên cột. SELECT emial FROM users là một lỗi biên dịch chứ không phải một cuộc gọi báo động lúc 3 giờ sáng.

Cảnh báo

Việc kiểm tra lúc biên dịch đối chiếu truy vấn với schema. Nó không kiểm chứng logic của bạn — một truy vấn có mệnh đề WHERE sai vẫn biên dịch ngon lành và trả về sai dòng. Cách này thay thế một lớp lỗi máy móc, chứ không thay thế các bài test của bạn.

Vấn đề CI, và sqlx prepare

Lời phản đối hiển nhiên: đòi một cơ sở dữ liệu sống mới biên dịch được thì bất khả thi với CI, với một lần clone mới tinh, và với bất cứ ai build mà không có một cái.

cargo sqlx prepare giải quyết việc đó. Nó chạy các phép kiểm tra một lần rồi ghi kết quả vào .sqlx/:

cargo sqlx prepare
git add .sqlx

Được commit vào, các bản build chạy ngoại tuyến — các macro đọc siêu dữ liệu đã lưu thay vì kết nối.

Hai điều suy ra từ đó, và cả hai cần nằm trong quy trình của bạn:

Chạy lại prepare mỗi khi một truy vấn hay schema thay đổi, rồi commit kết quả. Quên nghĩa là CI biên dịch với siêu dữ liệu cũ và pass trong khi môi trường thật thì hỏng.

Kiểm chứng nó trong CI:

cargo sqlx prepare --check

Câu đó làm hỏng bản build nếu dữ liệu đã lưu bị lạc hậu, thứ biến chuyện “ai đó quên mất” từ một sự cố sản xuất thành một pipeline đỏ.

Khi nào dùng API không phải macro

query! không xử lý được một truy vấn chưa biết lúc biên dịch. Với việc lọc động, API lúc chạy vẫn còn đó:

let mut builder = sqlx::QueryBuilder::new("SELECT id, name FROM users WHERE 1=1");

if let Some(name) = filter.name {
    builder.push(" AND name ILIKE ").push_bind(format!("%{name}%"));
}
if let Some(active) = filter.active {
    builder.push(" AND active = ").push_bind(active);
}

let users: Vec<User> = builder.build_query_as().fetch_all(&pool).await?;

Không có kiểm tra lúc biên dịch, và push_bind vẫn tham số hóa đúng cách — SQL động không có nghĩa là nối chuỗi, và đây là API khiến việc tiêm SQL trở nên bất khả thi.

Migration

Có sẵn, và là các file SQL thuần:

sqlx migrate add create_users
# sửa file migrations/20260709120000_create_users.sql
sqlx migrate run

Hãy chạy chúng lúc khởi động để một lần triển khai không thể chạy trên một schema cũ:

sqlx::migrate!("./migrations").run(&pool).await?;

Macro nhúng các file vào trong binary, nên chẳng có gì phải triển khai kèm theo.

Những điểm trừ thành thật

Thời gian biên dịch. Mỗi query! là một vòng đi về cơ sở dữ liệu trong một lần build mới tinh. Với chế độ ngoại tuyến thì nó đọc file, nhanh hơn nhưng không miễn phí.

Postgres là backend được hỗ trợ tốt nhất. MySQL và SQLite chạy được; phần suy luận về tính cho phép null thì yếu hơn, vì những cơ sở dữ liệu đó nói cho sqlx biết ít hơn.

Các lỗi có thể khó hiểu. Một chỗ lệch giữa một trường của struct và một cột sẽ sinh ra một lỗi bung macro cần một lúc mới đọc ra.

Không điều nào trong số đó lấn át được thứ chính yếu: sau một năm, tôi chưa phát hành một lỗi “no such column” hay “invalid input syntax for type” nào. Cả cái nhóm lỗi đó giờ là một lỗi biên dịch, và đó là lập luận mạnh nhất cho thư viện này.