13.2 查询对象

用对象表示查询条件与组合逻辑,再由映射层翻译为具体数据源查询。

学习目标

  • 能把一个订单筛选需求拆成查询意图、可组合条件、参数和值域校验,并说明每层的责任
  • 能用 TypeScript 代码实现一个不可泄漏 SQL 的查询对象,生成 SQL 与参数的成对结果
  • 能用边界输入和故障样本判断何时保留查询对象,何时改用专用查询方法或其他数据访问边界

为什么 13.2 查询对象值得单独学习

应用通常需要“已支付且金额不低于 100 的订单”这类筛选。若每个调用方直接拼 SQL,表名、列名、方言和用户输入会一起泄漏;若把所有可能筛选都做成固定 finder 方法,临时组合又会不断复制查询代码。把这两端拆开:调用方描述意图,查询对象保存结构,映射层最后翻译成目标数据源能执行的语句。

本章的判断标准不是“能否返回一组订单”,而是能否回答四个问题:条件由谁组合,字段是否合法由谁确认,值如何与 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} 这种危险文本传进映射层。

组合条件:让对象保存结构

的价值在于保存树,而不是提前保存字符串。每个节点都能单独测试,组合节点只负责表达逻辑;执行器随后递归这棵树,统一收集参数。

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 文本,还是参数绑定?再打开“注入直接拼接值”,观察为什么结果看似正确却失去安全边界;最后点击重置图示,确认阶段、错误状态和参数提示全部回到起点。

专属查询图 · 当前阶段:领域意图
Query Object:意图 → 条件树 → 参数化查询只描述要查什么领域意图OrderFilterstatus: "paid"minTotal: 100sort: "createdAt"不出现表名 / SQL 片段业务边界先固定允许值构建QueryObjecteq(status, "paid")gte(total, 100)and(left, right)元数据校验:字段白名单条件树可测、可复用、可翻译结构 ≠ SQL 文本翻译 + 绑定SQL + 参数WHERE status = $1AND total >= $2params: ["paid", 100]参数化通过rows → Order 结果映射方言只改变执行语法步骤 1 · 领域意图 · 只描述要查什么调用方选择受控字段;此时还没有表名、列名或数据库方言。验收:同一条件树应产生可检查的 SQL 文本、参数数组和结果映射边界。查询对象保存结构;翻译器负责方言,驱动负责参数,映射器负责领域结果
1 / 3 阶段 · 基线正常
查询对象将领域意图保存为可组合结构,再由方言适配生成参数化查询;故障开关展示直接拼接值会在哪里失守。
分步1 / 3

1. 领域意图:只描述要查什么

调用方创建过滤值并选择允许的领域字段;此时不出现表名、列名或数据库方言。图中左侧的输入卡是可测试的业务意图。

专属查询图 · 当前阶段:领域意图
Query Object:意图 → 条件树 → 参数化查询只描述要查什么领域意图OrderFilterstatus: "paid"minTotal: 100sort: "createdAt"不出现表名 / SQL 片段业务边界先固定允许值构建QueryObjecteq(status, "paid")gte(total, 100)and(left, right)元数据校验:字段白名单条件树可测、可复用、可翻译结构 ≠ SQL 文本翻译 + 绑定SQL + 参数WHERE status = $1AND total >= $2params: ["paid", 100]参数化通过rows → Order 结果映射方言只改变执行语法步骤 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.statusrow.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: 读者把 statusminTotal 两个条件组合为 AND。请说明查询对象、元数据校验、方言适配和结果映射各自负责什么,并指出哪一层最先拒绝未知排序字段。

问题 2:改 Demo 代码: 修改 renderOrderQuery,让 gte 条件返回 total >= $1 与参数 [100],并为组合条件补一个断言,证明 SQL 文本和参数顺序都正确。还要测试什么边界?

问题 3: 数据库把 total_cents 从数字改为字符串后,旧查询仍返回行。为什么不能在 mapOrder 中用 Number(row.total_cents) || 0 兜底?应怎样证明修复?

前后导航

来源与独立重写范围

本章未取得原书正文授权;公开目录和模式摘要只用于限定学习范围。中文讲解、TypeScript 代码、交互图、选择矩阵、陷阱、练习与答案均为本课程独立重写。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

查询对象

把一次查询的意图、条件和排序保存为对象结构,最后再翻译成数据源语句。

查询组合

把多个条件按 AND 或 OR 连接成一棵仍可检查和翻译的逻辑树。

元数据校验

在生成查询前检查允许字段、值类型、范围和排序白名单的边界。

参数化查询

把值放进驱动管理的参数集合,不把用户输入拼进 SQL 文本。

方言适配

把同一查询结构翻译成某种数据库的占位符、列名和分页语法。

结果映射

把数据库返回的行按类型、字段和单位契约转换成领域对象。

资料与写作方式声明

本章以Martin Fowler《企业应用架构模式》权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…