MyBatis 动态 SQL 与 TypeHandler:语句结构、参数绑定和类型转换
查询条件 tenantId=7、status=PAID 进入 MyBatis 后,会形成两份相互配合的信息:
SQL 结构:WHERE tenant_id = ? AND status_code = ?
参数位置:第 1 项 → tenantId;第 2 项 → status
类型转换:Long 7 → JDBC BIGINT;PAID → 业务码 20 → JDBC INTEGER动态 SQL 决定语句包含哪些部分,TypeHandler 决定值怎样写入 JDBC、又怎样从结果集中读回 Java。把这两层分开,空条件、错误排序和枚举转换失败就有了不同的检查位置。
查询条件怎样改变 SQL 结构
静态语句和动态语句在何时处理
Mapper XML 解析后,每条语句都有 SqlSource。普通静态文本通常使用 RawSqlSource,可以在启动解析时准备 SQL 与参数映射;含 if、foreach 或原文替换等动态部分的语句使用 DynamicSqlSource,要按本次参数生成 BoundSql。
动态节点构成一棵 SqlNode 树:文本节点输出 SQL 片段,IfSqlNode 根据条件选择是否输出,ForEachSqlNode 遍历集合,WhereSqlNode 和 SetSqlNode 处理前后缀。每次调用创建 DynamicContext,节点把本次结果写入上下文,再产生 SQL、参数映射和附加参数。DynamicSqlSource 实现
常用节点的职责如下:
| 节点 | 对 SQL 结构的作用 | 应在业务层确定的内容 |
|---|---|---|
if | 条件满足时包含片段 | 哪些查询条件允许省略 |
choose/when/otherwise | 选择一个分支 | 可接受的查询模式或排序选项 |
where | 有内容时加 WHERE,并修剪开头 AND/OR | 必要条件是否存在 |
set | 加 SET,并修剪尾部逗号 | 是否至少有一项允许更新 |
trim | 自定义前后缀及删除规则 | 最终 SQL 的含义 |
foreach | 展开集合元素、分隔符和括号 | 空集合意味着什么、元素数量上限 |
bind | 将表达式结果存入动态上下文 | 表达式与输入值是否符合业务约定 |
这些节点的完整语法见 MyBatis 动态 SQL。where 修好一个开头多余的 AND,并不意味着租户、权限或更新范围已经正确。
参数名和 OGNL 表达式
多参数方法可以用 @Param 给每个值明确命名:
List<Order> search(
@Param("tenantId") Long tenantId,
@Param("status") OrderStatus status,
@Param("ids") List<Long> ids,
@Param("sort") String sort,
@Param("pattern") String pattern);XML 中的 status != null 和 ids.size() > 0 在参数上下文中求值。嵌套属性必须先考虑外层对象是否为空;列表、Map 和数组的访问形式也应与实际 Java 对象一致。
没有 @Param 时,可用名称还受参数数量、集合包装、useActualParamName 与编译器 -parameters 影响。不要只凭 IDE 显示的形参名推测部署产物中的反射名称。参数解析规则和相关设置可查 MyBatis 配置。
问号参数和原文替换
两种写法作用不同:
WHERE tenant_id = #{tenantId}
ORDER BY ${sortFragment}#{tenantId} 生成问号和 ParameterMapping,驱动接收的是值。${sortFragment} 把内容插入 SQL 文本,能够改变语法结构;它不会自动建立一个安全的排序字段映射。Mapper 参数与字符串替换
表名、列名和排序方向属于 SQL 结构,不能直接用问号代替。常用做法是把外部选项限制成枚举或固定字符串,在 XML 的 choose 分支中输出预先编写的 SQL:
ORDER BY
<choose>
<when test="sort == 'amount'">amount DESC, id ASC</when>
<otherwise>id ASC</otherwise>
</choose>服务层先拒绝不支持的 sort 值,XML 只负责选择已有片段。分页排序还需要唯一的次序补充项;这里加上 id,使金额相同时仍有确定顺序。
用租户查询运行动态条件
建立独立环境
下载完整实验 ZIP。工程固定 MyBatis 3.5.19、pgJDBC 42.7.13、PostgreSQL 18.6;使用 Maven 3.9.12 和 JDK 25,编译目标为 Java 17。核心文件可直接查看 OrderMapper.xml、StatusHandler.java 和 DynamicLab.java。
在已安装 Docker Engine、Compose、unzip、openssl 的 Linux Bash 中执行:
test "$(id -u)" -ne 0 || exit 1
docker version
docker compose version
unzip mybatis-dynamic-typehandler-lab.zip
cd mybatis-dynamic-typehandler
export LAB_DIR="$PWD"
export LAB_CACHE="$LAB_DIR/.m2-cache"
mkdir -p "$LAB_CACHE"
export LAB_DB_PASSWORD="$(openssl rand -hex 24)"
export LAB_APP_PASSWORD="$(openssl rand -hex 24)"
export LAB_DB_URL='jdbc:postgresql://db:5432/dynamic_lab'
docker compose -p da10-dynamic up -d --wait数据库进程使用容器中的 999:999,应用容器使用普通宿主用户 UID/GID;数据库管理角色 lab_owner 负责初始化,Java 使用普通角色 lab_app。没有宿主端口映射,数据库目录为 tmpfs,停止容器即丢失实验数据。
init.sh 准备的数据有意包含两个租户和一个带 LIKE 特殊字符的名称:
| id | tenant_id | status_code | label | amount |
|---|---|---|---|---|
| 1 | 7 | 10 | A_100% | 10.25 |
| 2 | 7 | 20 | Beta | 20.50 |
| 3 | 8 | 10 | Other tenant | 30.75 |
状态列允许 NULL,金额列为 numeric(12,2)。实验会修改并恢复 id=1 的状态,因此只能在这套独立数据库中运行。
确认初始化:
docker compose -p da10-dynamic exec -T db \
psql -X -U lab_owner -d dynamic_lab -v ON_ERROR_STOP=1 \
-c "SELECT id, tenant_id, status_code, label, amount FROM purchase_order ORDER BY id"应该得到上述三行;失败时先查看 docker compose -p da10-dynamic logs --tail=80 db。
完整 SQL 条件的组织方式
Mapper 的查询首先保留必需的租户条件,再追加可选状态、ID 集合和名称模式:
<select id="search" resultMap="order">
SELECT id, status_code, label, amount
FROM purchase_order
<where>
tenant_id = #{tenantId,jdbcType=BIGINT}
<if test="status != null">
AND status_code =
#{status,jdbcType=INTEGER,typeHandler=example.StatusHandler}
</if>
<if test="ids != null">
<choose>
<when test="ids.size() > 0">
AND id IN
<foreach collection="ids" item="item"
open="(" separator="," close=")">
#{item,jdbcType=BIGINT}
</foreach>
</when>
<otherwise>AND 1 = 0</otherwise>
</choose>
</if>
<if test="pattern != null">
AND label LIKE #{pattern} ESCAPE '!'
</if>
</where>
ORDER BY
<choose>
<when test="sort == 'amount'">amount DESC, id ASC</when>
<otherwise>id ASC</otherwise>
</choose>
</select>这里区分 ids=null 与 ids=[]:前者表示不使用 ID 过滤,后者表示指定的集合为空,返回零行。若只是把空 foreach 的输出省略,原本按 ID 查几行的请求可能变成查整个租户。
SearchService.java 要求正数 tenantId,限制排序值,并把按字面匹配的名称转成 LIKE pattern。名称中的 !、%、_ 分别编码为 !!、!%、!_,与 SQL 的 ESCAPE '!' 配套:
String pattern = literal == null ? null
: "%" + literal.replace("!", "!!")
.replace("%", "!%")
.replace("_", "!_") + "%";参数化避免值变成 SQL 语法;LIKE 的通配符仍是查询语义的一部分,要另行决定用户输入表示通配模式还是普通文字。
注册处理器并完成首次查询
Factories.java 建立 DataSource 与 JDBC 事务工厂,在解析 Mapper XML 前注册自定义处理器:
Configuration configuration = new Configuration(
new Environment("lab", new JdbcTransactionFactory(), dataSource()));
configuration.getTypeHandlerRegistry().register(StatusHandler.class);
try (InputStream xml =
Factories.class.getResourceAsStream("/example/OrderMapper.xml")) {
if (xml == null) throw new IOException("Missing OrderMapper.xml");
new XMLMapperBuilder(xml, configuration, "example/OrderMapper.xml",
configuration.getSqlFragments()).parse();
}
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(configuration);结果对象、枚举、接口和完整 POM 都随工程交付。运行函数与首次查询:
lab_java() {
docker run --rm --user "$(id -u):$(id -g)" --read-only \
--network da10-dynamic_default --tmpfs /tmp:rw,exec,mode=1777 \
--mount "type=bind,src=$LAB_DIR,dst=/src,readonly" \
--mount "type=bind,src=$LAB_CACHE,dst=/cache" \
-e LAB_DB_URL -e LAB_APP_PASSWORD -e MAVEN_CONFIG=/tmp/maven \
maven:3.9.12-eclipse-temurin-25 bash /src/run.sh "$@"
}
lab_java first成功输出:
tenantId=7 rows=1 orderId=1 status=NEW amount=10.25程序使用 tenantId=7、status=NEW 查询,读取到 id=1,而没有把另一个租户同为 NEW 的 id=3 带回来。金额通过 BigDecimal 比较,状态通过真实 TypeHandler 映射。
查看 foreach 展开后的参数
执行:
lab_java boundsqlSource=DynamicSqlSource parameterSlots=3 foreachValues=[1, 2]
hashParametersRemainPlaceholders=true dollarFragmentBecameSqlText=true第一项参数属于 tenantId,另外两项属于 ID 集合。foreach 的每次迭代需要保存不同的元素值,因此 BoundSql 中包含附加参数;不能用最后一次循环的 item 值覆盖所有位置。
实验从实际 ParameterMapping 取出属性名,再通过 getAdditionalParameter 取得 1 和 2。测试不把内部生成名如 __frch_... 的具体序号硬编码成业务接口。
第二行还比较了一个固定、无害的 ORDER BY id DESC 原文片段。该方法只用于检查 BoundSql,不执行外部输入的原文 SQL;它直观显示了问号绑定和字符串替换的区别。
空集合、租户和排序同时检查
lab_java filtersemptyIdsReturnedZero=true tenantRetained=true literalLikeMatched=true whitelistSort=true
missingTenantRejected=true rawSortRejected=true这组实验检查空集合返回零行,ID 集合包含其他租户数据时仍只返回当前租户两行,A_100% 按字面名称匹配成功,金额排序按预期生效。空租户和未允许的原文排序在进入 Mapper 前被拒绝。
在实际应用中,tenantId 应来自可信的用户与租户授权关系。要求参数非空只解决输入完整性;仍需确认调用者有权访问该租户,不能把请求传来的任意数字直接当成已授权身份。
TypeHandler 怎样完成双向转换
注册表负责选处理器,处理器负责读写值
MyBatis 根据 Java 类型、JDBC 类型及显式映射寻找 TypeHandler。ParameterMapping 中已有处理器时,参数处理器按顺序取得值并调用它;遇到 foreach 附加参数时,需要从 BoundSql 的上下文取值。绑定位置传给 JDBC 时从 1 开始。DefaultParameterHandler API
ResultMap 可以显式指定处理器;全局注册则可以按类型匹配。@MappedTypes 标明 Java 类型,@MappedJdbcTypes 限制 JDBC 类型匹配;ResultMap 选择阶段未提供明确 JDBC 类型时,还要考虑 null JDBC 类型的匹配配置。TypeHandlerRegistry
显式指定适合个别列有特殊存储协议的情况;全局注册则适合该 Java 类型在整个应用都采用同一种编码。一个处理器同时被多次调用,不应把某个请求的参数保存为可变实例字段。
枚举使用稳定业务码
OrderStatus.java 将枚举映射为明确业务码:
public enum OrderStatus {
NEW(10), PAID(20);
private final int code;
OrderStatus(int code) { this.code = code; }
public int code() { return code; }
public static OrderStatus fromCode(int code) {
for (OrderStatus status : values()) {
if (status.code == code) return status;
}
throw new IllegalArgumentException("Unknown order status code: " + code);
}
}枚举顺序可以因维护代码改变,使用 ordinal 会把声明位置变成数据库协议。默认 EnumTypeHandler 采用枚举名称;若名称也可能变化,明确业务码和迁移规则更容易长期维护。相关默认处理器及枚举配置见 MyBatis 类型处理器说明。
处理器继承 BaseTypeHandler,写入使用 setInt,读取后检查 wasNull:
@MappedTypes(OrderStatus.class)
@MappedJdbcTypes(value = JdbcType.INTEGER, includeNullJdbcType = true)
public final class StatusHandler extends BaseTypeHandler<OrderStatus> {
@Override
public void setNonNullParameter(PreparedStatement statement, int index,
OrderStatus value, JdbcType jdbcType) throws SQLException {
statement.setInt(index, value.code());
}
@Override
public OrderStatus getNullableResult(ResultSet rows, String column)
throws SQLException {
int code = rows.getInt(column);
return rows.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(ResultSet rows, int column)
throws SQLException {
int code = rows.getInt(column);
return rows.wasNull() ? null : OrderStatus.fromCode(code);
}
@Override
public OrderStatus getNullableResult(CallableStatement statement, int column)
throws SQLException {
int code = statement.getInt(column);
return statement.wasNull() ? null : OrderStatus.fromCode(code);
}
}下载源码额外加入 AtomicInteger 回调计数,供实验验证处理器确实被调用。三个读取重载分别服务按列名、列序号和存储过程输出参数的读取入口;它们都应保留 SQL NULL 的含义。BaseTypeHandler API
写入和读取都要经过真实映射
XML 中的更新与结果映射为:
<update id="updateStatus">
UPDATE purchase_order
<set>
status_code = #{status,jdbcType=INTEGER,typeHandler=example.StatusHandler}
</set>
WHERE id = #{id}
</update>
<resultMap id="order" type="example.Order">
<id column="id" property="id"/>
<result column="status_code" property="status"
typeHandler="example.StatusHandler"/>
<result column="label" property="label"/>
<result column="amount" property="amount"/>
</resultMap>lab_java roundtriphandlerWriteObserved=true handlerReadObserved=true storedCode=20 roundtrip=PAID程序通过 Mapper 更新为 PAID 并提交,在另一会话读取回 PAID,同时用 JDBC 查询数据库中实际存储的 20。写入与读取回调计数均大于零;单独调用 fromCode(20) 的单元测试无法替代这些接入检查。
NULL 与未知值分别处理
lab_java nullssqlNullStored=true nullPreserved=true nonNullWriteCallbackSkipped=true传入 null 时,BaseTypeHandler 走空值绑定,setNonNullParameter 不会被调用。XML 明确给出 jdbcType=INTEGER,让驱动知道空值的目标类型。读取端若只写 getInt 而不检查 wasNull,就会把 NULL 误读为 0。
未知码是另一个问题:
lab_java unknownunknownCodeRejected=true repairedCode=10 mappedStatus=NEW实验先直接把状态码改成 99,真实 Mapper 查询因无法转换而失败;将数据恢复为 10 后重新查询成功。服务端 SQL 本身可能正常返回,错误发生在客户端对象映射阶段,不能只查看数据库慢日志。
严格拒绝未知码适合要求所有状态有明确语义的业务。需要前后版本共存时,可以设计 UNKNOWN 状态并保留原始码,但不能静默把未知值映射成 NEW,这会改变业务含义。增加新状态时应协调数据库数据、读端兼容和写端启用次序。
结果映射和类型故障怎样定位
列名、构造器与嵌套结果
ResultMap 的 property 面向 Java 属性,column 面向结果集标签。多表连接返回同名列时,应使用不同别名,再明确映射;自动驼峰规则不能区分“订单 id”和“客户 id”。
不可变对象可以使用构造器映射。构造参数名称、类型、顺序要和映射方式一致,不能只把 POJO 改成 record 后期待所有旧配置仍按 setter 工作。嵌套关联和集合映射还要正确标识主键,避免同一个对象重复创建或不同对象误合并。MyBatis ResultMap
常见类型的选择
| 数据含义 | 常用 Java / JDBC 表达 | 主要检查 |
|---|---|---|
| 金额 | BigDecimal / NUMERIC | 精度、scale、舍入约定 |
| 可空整数 | Integer/Long 或显式 wasNull | 空值不能落成 0 |
| 日期与本地时间 | LocalDate、LocalDateTime | 与列的是否带时区语义一致 |
| 时间点 | PostgreSQL timestamptz / OffsetDateTime | 不把本地墙上时间误当统一时间点 |
| 枚举状态 | 稳定业务码 + 自定义处理器 | 未知码、NULL、版本兼容 |
| JSONB 等扩展类型 | 驱动扩展对象或显式数据库转换 | 不能仅把任意 String 当成正确列类型 |
pgJDBC 的常用 Java 时间映射见驱动查询说明。驱动扩展类型可通过 PGobject API描述类型名称和值,例如为 JSONB 明确类型;还要选择 JSON 编解码器、校验字段结构和限制内容大小。
LOB 或流式参数需要确认流的读取时点与关闭者。处理器只负责转换值,不应暗中发起额外数据库查询或远程请求,否则每行结果都可能引入新的 I/O,并扩大连接占用时间。
把问题落到实际对象
| 现象 | 优先查看 | 处理方向 |
|---|---|---|
Parameter ... not found | Mapper @Param、BoundSql 参数属性 | 统一参数名称 |
| OGNL 属性不存在或求值失败 | test 表达式和实际参数对象 | 修正属性路径及空值判断 |
SQLSTATE 42601 | 本次 BoundSql 的 SQL 文本 | 检查空 SET、括号、分隔符、方言 |
| 某列无法转换 | ResultMap、TypeHandler、数据库列类型 | 对齐 Java 与 JDBC 读取方式 |
| 查询范围扩大 | 空集合/空条件分支和租户条件 | 明确空输入的业务含义 |
| 同名列映射错对象 | SQL 别名和嵌套 ResultMap | 区分每个对象的列与主键 |
SQLSTATE 的完整分类使用 PostgreSQL 错误码表。检查表的真实列类型时,可在这套实验数据库执行:
docker compose -p da10-dynamic exec -T db \
psql -X -U lab_owner -d dynamic_lab -v ON_ERROR_STOP=1 -c "
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'purchase_order'
ORDER BY ordinal_position"把数据库列定义、XML 的 jdbcType/typeHandler 和 Java 字段并排比较,通常比同时改多处配置更容易确定原因。修复后复跑失败参数及正常参数,防止为兼容一个异常值而改变所有正常值的含义。
结束实验:
docker compose -p da10-dynamic ps
docker compose -p da10-dynamic down
unset LAB_DB_PASSWORD LAB_APP_PASSWORD LAB_DB_URL
unset -f lab_java仅删除该实验的容器与网络,tmpfs 数据不可恢复;源码与依赖缓存保留。
权威资料与规范地址
动态节点与处理器规则按 MyBatis 3.5.19 查阅;类型往返实验固定 pgJDBC 42.7.13 和 PostgreSQL 18.6。完整 API 与数据库类型差异可从下列地址继续查阅。
| 查阅对象 | 官方地址 |
|---|---|
| MyBatis 动态 SQL | https://mybatis.org/mybatis-3/dynamic-sql.html |
| DynamicSqlSource 实现 | https://mybatis.org/mybatis-3/xref/org/apache/ibatis/scripting/xmltags/DynamicSqlSource.html |
| MyBatis 配置和类型处理器 | https://mybatis.org/mybatis-3/configuration.html |
| Mapper 参数与 ResultMap | https://mybatis.org/mybatis-3/sqlmap-xml.html |
| DefaultParameterHandler | https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/scripting/defaults/DefaultParameterHandler.html |
| TypeHandlerRegistry | https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/type/TypeHandlerRegistry.html |
| BaseTypeHandler | https://mybatis.org/mybatis-3/apidocs/org/apache/ibatis/type/BaseTypeHandler.html |
| pgJDBC 类型读取 | https://jdbc.postgresql.org/documentation/query/ |
| pgJDBC PGobject 扩展类型 | https://jdbc.postgresql.org/documentation/publicapi/org/postgresql/util/PGobject.html |
| PostgreSQL 错误码 | https://www.postgresql.org/docs/18/errcodes-appendix.html |
