🎯 什麼情境該想到我

當你「想用物件來表達查詢條件,而不是到處手寫 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 或固定查詢。

🔗 相關工具