🎯 什麼情境該想到我
當你「想用物件來表達查詢條件,而不是到處手寫 SQL 字串」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把一次資料庫查詢表示成一個物件,讓查詢條件可以用物件方式組合,並與底層資料庫細節解耦。
做法要點:
- 用一個查詢物件承載條件(欄位、運算子、值)與排序等資訊。
- 查詢物件以領域層的語彙描述條件,再由框架翻譯成實際 SQL。
- 條件可程式化地組合、疊加,適合動態查詢畫面。
- 是 GoF 直譯器(Interpreter)模式的一種應用。
★★ 它要解決的問題:動態條件的字串拼接
// ★★ 沒有查詢物件時的樣子 —— 每個人都寫過這段,每個人都寫錯過
q := "SELECT * FROM orders WHERE 1=1"
args := []any{}
if f.CustomerID != "" {
q += " AND customer_id = $" + strconv.Itoa(len(args)+1) // ★★ 佔位符編號手算
args = append(args, f.CustomerID)
}
if f.Status != "" {
q += " AND status = $" + strconv.Itoa(len(args)+1)
args = append(args, f.Status)
}
// ★ 問題:① 佔位符編號手算容易錯 ② WHERE 1=1 的小技巧
// ③ ★★ 只要有人「順手」用 fmt.Sprintf 插一個值 → SQL injection★★ Go 的三種做法,按推薦度排序
// ① ★★ 固定查詢 + 可選參數(sqlc 能做,最安全)
-- name: ListOrders :many
SELECT * FROM orders
WHERE (sqlc.narg('customer_id')::text IS NULL OR customer_id = sqlc.narg('customer_id'))
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'));
// ★ 型別安全、無字串拼接、SQL 可見
// ⚠️ 代價:① 條件多時 SQL 很長 ② ★ 查詢計畫可能不最佳(優化器要處理 IS NULL 分支)
// ② ★ query builder(squirrel 之類)—— 佔位符由它管
sb := squirrel.Select("*").From("orders").PlaceholderFormat(squirrel.Dollar)
if f.CustomerID != "" { sb = sb.Where(squirrel.Eq{"customer_id": f.CustomerID}) }
sql, args, err := sb.ToSql()
// ★ 佔位符不會算錯;★ 但欄位名仍是字串,打錯執行期才知道
// ③ ★★ 自己寫一個型別安全的查詢物件(★ 條件組合真的很複雜時才值得)
type OrderQuery struct {
customerID *CustomerID
statuses []Status
placedAfter *time.Time
limit, offset int
}
func (q OrderQuery) CustomerID(id CustomerID) OrderQuery { q.customerID = &id; return q } // ★ 值接收者 → 不可變、可鏈式★★ ③ 的關鍵設計:值接收者 + 回傳新值,讓查詢物件不可變、可安全共用、可鏈式組合
(見 值物件)。
★★ 三個安全與正確性的硬要求
// ① ★★ 欄位名與排序方向永遠用白名單,絕不從輸入拼進 SQL
var allowedSort = map[string]string{
"created": "created_at", "total": "total_cents",
}
col, ok := allowedSort[req.SortBy]
if !ok { return ErrInvalidSort }
dir := "ASC"; if req.Desc { dir = "DESC" }
sql := fmt.Sprintf("SELECT … ORDER BY %s %s", col, dir) // ★ 只有白名單值進得來
// ★★ ORDER BY 無法用佔位符($1)—— 這是 SQL injection 最常見的漏口
// ② ★★ LIMIT 必須有預設值與上限
if req.Limit <= 0 || req.Limit > 200 { req.Limit = 50 }
// ★ 沒有上限 → 一個 limit=999999999 的請求就能把服務打掛
// ③ ★★ 深分頁:OFFSET 會越來越慢
// OFFSET 100000 要掃過前 100000 列再丟掉
// ✅ 用 keyset pagination(cursor)
WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3
// ★ 恆定成本,而且不會因為插入新資料而重複/漏掉★★ ① 是最重要的一條:ORDER BY 和欄位名無法用佔位符,
所以它們必須走白名單——這是實務上 SQL injection 最常出現的地方。
★★ 什麼時候不要做查詢物件
① ★★ 只有兩三種固定的查詢組合
→ ★ 寫三個具名的查詢(ListByCustomer/ListByStatus/ListRecent)
比一個能組合的查詢物件更清楚、更好調效能
→ ★★ 具名查詢的好處:可以各自加索引、各自 EXPLAIN
② ★ 條件其實來自不同的使用場景
→ 報表用的查詢和列表用的查詢硬合成一個 → ★ 查詢計畫一定有一邊不好
→ ★ 這是 CQRS 的論點:★★ 針對場景設計查詢,不要追求通用
③ ★ 你其實需要的是全文檢索/複雜聚合
→ ★ 那該去 Elasticsearch/OLAP,不是把 SQL builder 做得更強
★★ ② 的判準很實用:EXPLAIN 兩種極端的參數組合。
若計畫差很多(一個走索引、一個全表掃描)→ 它們該是兩個查詢。
★ 兄弟辨析
| 差別 | |
|---|---|
| 中繼資料對應 | ★ 查詢物件通常建立在它之上 |
| 儲存庫 | ★ 常以查詢物件作為參數(repo.Find(q)) |
| 表格資料閘道 | ★ 預先寫好的固定查詢;★ 多數情況更好 |
| 表格模組 | 報表式查詢的舊名字 |
| 命令模式 | 把請求物件化的一般形式 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
要涵蓋各種 SQL 能力會讓查詢物件本身變得複雜。
-
簡單、固定的查詢直接寫 SQL 更省事。
-
需搭配中繼資料對應才能把物件條件翻成正確 SQL。
-
★★
ORDER BY與欄位名無法用佔位符,必須走白名單。 這是實務上 SQL injection 最常出現的地方。 -
★★
LIMIT必須有預設值與上限。 沒有上限,一個limit=999999999就能打掛服務。 -
★★ 深分頁用 keyset(cursor)而非
OFFSET。 OFFSET 100000 要掃過前十萬列;keyset 是恆定成本且不會重複/漏資料。 -
★★ 只有兩三種固定組合 → 寫具名查詢更好:可以各自加索引、各自
EXPLAIN。 -
★★ 判準:
EXPLAIN兩種極端參數組合。 計畫差很多就該是兩個查詢。 -
★ 「IS NULL OR 欄位 = 參數」的可選條件會讓查詢計畫變差,優化器要處理所有分支。
-
★ query builder 的欄位名仍是字串,打錯執行期才知道。
-
不要為了「通用」把報表查詢和列表查詢合成一個。 那是 CQRS 反對的事。
-
手算佔位符編號(
$1、$2)是高頻 bug。用 builder 或固定查詢。
🔗 相關工具
- 儲存庫 Repository(內部常用查詢物件組裝查詢)
- 中繼資料對應 Metadata Mapping(提供物件與欄位對應,讓查詢物件能翻成 SQL)
- 企業應用架構模式
- 中繼資料對應 —— 查詢物件建立在它之上
- 儲存庫 —— 常以查詢物件為參數
- 表格資料閘道 —— ★ 固定具名查詢,多數情況更好
- 表格模組 —— 報表式查詢
- 命令模式 —— 把請求物件化
- 延遲載入 —— 針對場景設計查詢避免過度載入