13.2 查询对象
用对象表示查询条件与组合逻辑,再由映射层翻译为具体数据源查询。
学习目标
- 能把一个订单筛选需求拆成查询意图、可组合条件、参数和值域校验,并说明每层的责任
- 能用 TypeScript 代码实现一个不可泄漏 SQL 的查询对象,生成 SQL 与参数的成对结果
- 能用边界输入和故障样本判断何时保留查询对象,何时改用专用查询方法或其他数据访问边界
为什么 13.2 查询对象值得单独学习
应用通常需要“已支付且金额不低于 100 的订单”这类筛选。若每个调用方直接拼 SQL,表名、列名、方言和用户输入会一起泄漏;若把所有可能筛选都做成固定 finder 方法,临时组合又会不断复制查询代码。↡表示一次数据库查询意图、条件组合和排序,而不是一段已经拼好的 SQL 文本把这两端拆开:调用方描述意图,查询对象保存结构,映射层最后翻译成目标数据源能执行的语句。
本章的判断标准不是“能否返回一组订单”,而是能否回答四个问题:条件由谁组合,字段是否合法由谁确认,值如何与 SQL 分离,结果如何恢复为领域对象。查询对象带来的间接层只有在这些责任可以被测试、复用和替换时才值得支付。
本文依据 Martin Fowler 的公开模式目录限定 13.2 查询对象的边界;案例、TypeScript 代码、交互图、练习和答案均为本课程独立重写,不复现原书正文、插图或代码。
先画出查询语义,而不是先写 SQL
固定一个订单列表用例:用户可以选择支付状态、最低金额和排序字段。领域入口只接受受控值,不能让输入直接携带表名、列名或 SQL 片段。先把“我要什么”写成数据结构,再决定如何执行。
type OrderFilter = {
status?: "paid" | "pending";
minTotal?: number;
sort?: "createdAt" | "total";
};
const filter: OrderFilter = {
status: "paid",
minTotal: 100,
sort: "createdAt",
};这里的 OrderFilter 不是查询对象本身,它只是边界输入。查询对象需要把输入转换成可检查的条件节点,并拒绝未知字段、负数金额和未允许的排序方向。这样,调用方不需要知道数据库列叫 created_at,也不会把 ORDER BY ${userInput} 这种危险文本传进映射层。
组合条件:让对象保存结构
↡把两个或多个条件节点按 AND、OR 等逻辑连接起来,并保留连接结构供后续翻译的价值在于保存树,而不是提前保存字符串。每个节点都能单独测试,组合节点只负责表达逻辑;执行器随后递归这棵树,统一收集参数。
type Predicate =
| { kind: "eq"; field: "status"; value: "paid" | "pending" }
| { kind: "gte"; field: "total"; value: number }
| { kind: "and"; left: Predicate; right: Predicate };
const predicate: Predicate = {
kind: "and",
left: { kind: "eq", field: "status", value: "paid" },
right: { kind: "gte", field: "total", value: 100 },
};发生在翻译之前。它不是把数据库 schema
全部复制到业务层,而是为本查询公开一份最小白名单:status 只能取两种值,total
只能比较数字,排序只能选已登记的领域字段。未知字段应得到明确错误,而不是在数据库执行时才暴露。
三步专属实验:从意图到可执行查询
先预测:点击下一阶段后,哪一项会新增——条件结构、SQL 文本,还是参数绑定?再打开“注入直接拼接值”,观察为什么结果看似正确却失去安全边界;最后点击重置图示,确认阶段、错误状态和参数提示全部回到起点。
1. 领域意图:只描述要查什么
调用方创建过滤值并选择允许的领域字段;此时不出现表名、列名或数据库方言。图中左侧的输入卡是可测试的业务意图。
参数化与方言适配:翻译器承担最后一公里
↡把查询值放进独立参数集合,由驱动绑定,而不是把值拼进 SQL 字符串让结构与数据分离。下面的结果不是完整 ORM 实现,而是一个可测试的合同:SQL 中只有占位符,参数数组保留原始值和顺序。
type RenderedQuery = { text: string; params: readonly unknown[] };
function renderOrderQuery(p: Predicate): RenderedQuery {
if (p.kind === "eq") return { text: "status = $1", params: [p.value] };
if (p.kind === "gte") return { text: "total >= $1", params: [p.value] };
const left = renderOrderQuery(p.left);
const right = renderOrderQuery(p.right);
return {
text: `(${left.text} AND ${right.text})`,
params: [...left.params, ...right.params],
};
}不同数据库的占位符可能是 $1、? 或 @p1。↡把同一棵查询结构翻译为某种数据库的占位符、分页和标识符规则应位于查询对象之后,而不是散落在业务调用方。它可以替换占位符编号和列名映射,却不能改变 AND、字段白名单或参数的含义。测试应同时断言结构结果和参数列表,避免只比较一条偶然生成的字符串。
结果映射:查询成功不等于对象正确
数据库返回的行仍是数据源形状,例如 created_at 是字符串或时间戳、total_cents 是整数;领域层需要一个明确的↡把数据源返回的行按字段契约转换为领域对象,并在缺字段或类型不符时拒绝边界。不要让每个调用方各自读取 row.status、row.created_at,否则同一查询的语义会在不同页面分叉。
type Order = { id: string; status: "paid" | "pending"; total: number };
function mapOrder(row: Record<string, unknown>): Order {
if (typeof row.id !== "string") throw new Error("invalid order id");
if (row.status !== "paid" && row.status !== "pending")
throw new Error("invalid order status");
if (typeof row.total_cents !== "number")
throw new Error("invalid order total");
return { id: row.id, status: row.status, total: row.total_cents / 100 };
}这条边界让故障可定位:条件构造错误属于查询对象,字段名或范围错误属于元数据校验,SQL/参数不一致属于方言适配,行字段不完整属于结果映射。一个“查不到数据”的最终现象不能替代这四种不同的诊断。
选择与拒绝矩阵
| 评审问题 | 保留查询对象的证据 | 应改用专用方法或其他边界的信号 |
|---|---|---|
| 组合 | 多个筛选可复用、嵌套且能保持语义 | 只有一个稳定条件,组合层只增加包装 |
| 安全 | 值始终进入参数集合,字段来自白名单 | 调用方仍能注入列名、方向或 SQL 片段 |
| 演进 | schema 变化集中在元数据与方言适配 | 每次迁移都要修改大量调用方字符串 |
| 结果 | 行到领域对象有统一映射和失败分类 | 不同页面各自解释同一个返回字段 |
| 性能 | 能查看最终 SQL、参数、索引和执行计划 | 查询树很漂亮,却无法定位慢查询或错误计划 |
查询对象不自动保证性能,也不等于“把 SQL 隐藏起来就完成抽象”。评审时要保留一个真实生成结果:查询结构、最终 SQL、参数、返回行数量和映射错误。若查询始终只有一个固定形状,专用 finder 方法可能更诚实;若领域需要跨聚合拼接筛选,查询对象才有足够的复用价值。
常见误区
本章小结
- 查询对象保存查询意图与条件结构,不保存调用方拼好的 SQL。
- 查询组合保留逻辑树,元数据校验先拒绝未知字段和值域。
- 参数化查询把结构和数据分离,方言适配只处理数据源语法差异。
- 结果映射把数据行转换为领域对象,并为坏行提供可定位错误。
- 组合复杂度、性能可见性和迁移成本共同决定是否保留该模式。
本章练习
练习
问题 1: 读者把 status 和 minTotal 两个条件组合为 AND。请说明查询对象、元数据校验、方言适配和结果映射各自负责什么,并指出哪一层最先拒绝未知排序字段。
问题 2:改 Demo 代码: 修改 renderOrderQuery,让 gte 条件返回 total >= $1 与参数 [100],并为组合条件补一个断言,证明 SQL 文本和参数顺序都正确。还要测试什么边界?
问题 3: 数据库把 total_cents 从数字改为字符串后,旧查询仍返回行。为什么不能在 mapOrder 中用 Number(row.total_cents) || 0 兜底?应怎样证明修复?
前后导航
来源与独立重写范围
- Martin Fowler 的 Query Object 模式摘要:核对模式定义与问题边界。
- Martin Fowler 企业应用架构模式目录:核对 13.2 所属的对象-关系元数据映射模式族。
- Martin Fowler 作者图书页:核对图书、目录与公开参考范围。
- Pearson 出版社页面:交叉核对出版信息。
本章未取得原书正文授权;公开目录和模式摘要只用于限定学习范围。中文讲解、TypeScript 代码、交互图、选择矩阵、陷阱、练习与答案均为本课程独立重写。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 查询对象
把一次查询的意图、条件和排序保存为对象结构,最后再翻译成数据源语句。
- 查询组合
把多个条件按 AND 或 OR 连接成一棵仍可检查和翻译的逻辑树。
- 元数据校验
在生成查询前检查允许字段、值类型、范围和排序白名单的边界。
- 参数化查询
把值放进驱动管理的参数集合,不把用户输入拼进 SQL 文本。
- 方言适配
把同一查询结构翻译成某种数据库的占位符、列名和分页语法。
- 结果映射
把数据库返回的行按类型、字段和单位契约转换成领域对象。