简介
动态SQL是Mybatis的一项核心功能,通过一份静态的XML配置 + 外部参数,动态生成最终的SQL语句,可以用很少的理解成本配置复杂条件的动态SQL,摆脱各种处理逗号、空格这些细枝末节的痛苦。
标签说明
要实现动态拼接SQL,需要在XML中提前配置好相应标签,Mybatis支持以下4类标签:
if
<if test="title != null">
AND title like #{title}
</if>
if标签的作用是:传入指定参数后,如果 test 表达式执行结果为真,则将 <if></if>
中间包含的内容添加到生成的SQL语句中。
常见的用法是为where子句新增条件。
choose (when, otherwise)
<choose>
<when test="title != null">
AND title like #{title}
</when>
<when test="author != null and author.name != null">
AND author_name like #{author.name}
</when>
<otherwise>
AND featured = 1
</otherwise>
</choose>
这一系列标签的作用是从多个条件中选择一个使用,类似于代码中 switch、case、default,语义如下
if(title != null){
sql += 'AND title like #{title}';
return;
}else if(author != null and author.name != null){
sql += 'AND author_name like #{author.name}';
return;
}else{
sql += 'AND featured = 1';
return;
}
trim (where, set)
if、choose 等标签可以用来解决根据参数动态选择拼接SQL片段的问题,但是只靠这种程度的动态拼接生成的语句基本是不可用的。
举个例子,我现在想按照邮箱查询用户信息表,如果只用if标签,会出现以下情况:
<select id="queryByEmail" resultMap="BaseResultMap">
SELECT * FROM `people`
WHERE
<if test="email != null">
AND email = #{email}
</if>
</select>
- 当email字段为null时,生成的语句是
SELECT * FROM people where
- email不为null:生成
SELECT * FROM people where AND email = ?
很明显,这两条SQL语法都是错误的。
trim
为了解决这一问题,Mybatis提供了 trim/where/set
这一系列标签,首先来看trim标签,支持配置以下属性
- prefix:前缀
- prefixesToOverride:前缀后需要被移除的内容,多个值使用 | 分隔
- suffix:后缀
- suffixesToOverride:后缀前需要被移除的内容,多个值使用 | 分隔
trim标签的作用是:当子节点生成的内容不为空时, 清除 prefixesToOverride/suffixesToOverride 对应的内容,再拼接上 prefix/suffix 对应的前后缀
我们可以改用trim标签改写按邮箱查询用户的例子
<select id="queryByEmail" resultMap="BaseResultMap">
SELECT * FROM `people`
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="email != null">
AND email = #{email}
</if>
</trim>
</select>
trim标签为我们做了以下的事情:
- 获取子节点内容
- email为null:子节点为空(不会进入下一步,对结果无影响)
- email不null:子节点内容为
AND email = #{email}
字符串
- 清除 prefixOverrides 对应内容,从
AND email = #{email}
变成email = #{email}
- 加上 prefix 对应的前缀
WHERE
所以整个trim 标签执行完后,生成这个结果 WHERE email = ${email}
where
当然,一般来说,这里也用不着trim标签,where用起来会更简单。
<select id="queryByEmail" resultMap="BaseResultMap">
SELECT * FROM `people`
<where>
<if test="email != null">
AND email = #{email}
</if>
</where>
</select>
二者之所以可以实现相同的功能,是因为where是trim指定了特定参数的一种简写形式,二者是等价的。
set
与where类似,set标签也是trim的一种简写形式,对应的参数如下:
<trim prefix="SET" suffixOverrides=",">
...
</trim>
可以用来动态指定更新哪些字段
foreach
foreach 标签用于对集合元素进行遍历,例如构建 in 条件
<select id="selectPostIn" resultType="domain.blog.Post">
SELECT *
FROM POST P
<where>
<foreach item="item" index="index" collection="list"
open="ID in (" separator="," close=")" nullable="true">
#{item}
</foreach>
</where>
</select>
动态Sql配置示例
<select id="queryByAgeAndEmail" resultMap="BaseResultMap">
SELECT * FROM `people`
<where>
<if test="age != null">
AND age = ${age}
</if>
<if test="email != null">
AND email = #{email}
</if>
</where>
</select>
写好XML配置后,执行查询语句
List<People> peopleList = mapper.queryByAgeAndEmail(9, "zzz@sample.com");
通过给StatementHandler设置拦截插件,打印出执行的sql语句及参数如下
耗时21 ms
sql:SELECT \* FROM `people`
WHERE age = 9
AND email = ?
param:{age=9, param1=9, email=<zzz@sample.com>, param2=<zzz@sample.com>}
我们已经了解了Mybatis有哪些动态SQL相关的标签及其作用。接下来,将了解这些是如何实现的。
首先,需要先了解两个概念 SqlSource 和 SqlNode
SqlSource
SqlSource是Mybatis中定义的接口,对应了 通过注解或xml配置的sql语句资源(select|update|insert),有以下4个实现类:
-
ProviderSqlSource:用于描述通过@Select 等注解配置的SQL
-
DynamicSqlSource:用于描述Mapper XML文件中配置的SQL
-
RawSqlSource:用于描述Mapper XML文件中配置的SQL资源信息,不包含动态SQL相关配置。
- 此处的动态指
<if|where>
等标签以及${}
占位符,但仍可能包含#{}
占位符 参见XMLScriptBuilder#parseScriptNode
- 此处的动态指
-
StaticSqlSource:用于描述前几种 sqlSource 解析后得到的静态SQL资源。它们会在参数解析后,最终生成 StaticSqlSource
xml配置信息到 SqlSource 的转换由 LanguageDriver 完成,MyBatis 自带两个实现类
- RawLanguageDriver:仅纯sql
- XMLLanguageDriver:@Select等注解 和 xml 标签配置的动态sql
还有其他的LanguageDriver,如 Velocity模板 对应 VelocityLanguageDriver (需要额外引入包)
SqlNode
SqlNode是一个接口,用于描述Mapper配置中的某条语句下的节点信息,包含以下的实现类:
以上文示例中的XML配置为例,初始化时载入这部分配置后,由于包含动态内容,解析并生成了DynamicSqlSource,主要的内容是 SqlNode 节点构造的树状结构。
根节点包含了3个子节点,分别为:
- StaticTextSqlNode: 纯文本节点,内容为
SELECT * FROM people
- WhereSqlNode:where节点,包含 3个只有换行/空白字符的纯文本节点 和 两个IF节点
- StaticTextSqlNode:换行符
更多明细详见下图
解析sql语句
调用 mapper.queryByAgeAndEmail() 执行查询时,首先会获取该方法对应的SqlSource,执行 DynamicSqlSource#getBoundSql
这一步获取最终要跑的sql语句。
getBoundSql
的代码如下:
@Override
public BoundSql getBoundSql(Object parameterObject) {
DynamicContext context = new DynamicContext(configuration, parameterObject);
rootSqlNode.apply(context); // 对节点树逐层调用apply,拼接内容到context中
// 此时 context 内容中已经去掉了全部的动态节点 和 ${} 占位符,#{} 还在
SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
Class<?> parameterType = parameterObject == null ? Object.class : parameterObject.getClass();
// 1. 处理占位符 #{},将其转化为?
// 2. 生成 StaticSqlSource 对象,然后由它生成最终的BoundSql
SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings());
BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
context.getBindings().forEach(boundSql::setAdditionalParameter);
return boundSql;
}
new DynamicContext(configuration, parameterObject)
准备用于拼接动态sql的上下文,其中包括了参数信息和构造出来的sql语句rootSqlNode.apply(context);
遍历SqlNode节点树,依次调用apply,将对应节点的内容拼接到context中,此处列举部分节点的处理方式:- StaticTextSqlNode: 纯静态sql语句片段,直接追加到 context
- TextSqlNode:使用参数替换 ${} 占位符后拼接内容到context
- WhereSqlNode:拼接WHERE,去除紧跟在后面的
AND |OR
(依赖TrimSqlNode,前文已有说明) - IfSqlNode:执行 Ognl 表达式,判断 test 对应的执行结果,true则拼接子节点的内容到context中
会直接附加sql片段到 context 的节点类型如下:
其他类型节点自身不会追加信息,而是遍历子节点时,由对应子节点来添加。
例如 IfSqlNode 会包含一个 StaticTextSqlNode 或 TextSqlNode 子节点(取决于子节点是否包含动态内容),当判断符合条件时,调用 子节点.apply(context)
去实现动态SQL拼接
tips:if 标签开头多余的 AND 是怎么去除的?
-
WhereSqlNode 指定了
AND|OR
作为前缀需要被覆盖,接着调用父类 TrimSqlNode 的实现 -
TrimSqlNode 中会生成一个新的临时 context ,存放where下所有子节点的sql片段(也就是说 IfSqlNode里拿到的context 与最外面传进来的context不是同一个)
-
执行去除前缀后,将临时 context 中的结果拼接到 最外层 context 上
forEach节点也采用了类似的临时context的方式
参考
- MyBatis 3源码深度解析-微信读书