likes
comments
collection
share

Mybatis动态SQL解析:XML配置如何变成最终的Sql语句?

作者站长头像
站长
· 阅读数 21

简介

动态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标签为我们做了以下的事情:

  1. 获取子节点内容
    • email为null:子节点为空(不会进入下一步,对结果无影响)
    • email不null:子节点内容为 AND email = #{email} 字符串
  2. 清除 prefixOverrides 对应内容,从 AND email = #{email} 变成 email = #{email} 
  3. 加上 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配置中的某条语句下的节点信息,包含以下的实现类:

Mybatis动态SQL解析:XML配置如何变成最终的Sql语句?

以上文示例中的XML配置为例,初始化时载入这部分配置后,由于包含动态内容,解析并生成了DynamicSqlSource,主要的内容是 SqlNode 节点构造的树状结构。

根节点包含了3个子节点,分别为:

  • StaticTextSqlNode: 纯文本节点,内容为 SELECT * FROM people
  • WhereSqlNode:where节点,包含 3个只有换行/空白字符的纯文本节点 和 两个IF节点
  • StaticTextSqlNode:换行符

更多明细详见下图 Mybatis动态SQL解析:XML配置如何变成最终的Sql语句?

解析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 的节点类型如下: Mybatis动态SQL解析:XML配置如何变成最终的Sql语句?

其他类型节点自身不会追加信息,而是遍历子节点时,由对应子节点来添加。

例如 IfSqlNode 会包含一个 StaticTextSqlNode 或 TextSqlNode 子节点(取决于子节点是否包含动态内容),当判断符合条件时,调用 子节点.apply(context)  去实现动态SQL拼接

tips:if 标签开头多余的 AND 是怎么去除的?

  1. WhereSqlNode 指定了 AND|OR  作为前缀需要被覆盖,接着调用父类 TrimSqlNode 的实现

  2. TrimSqlNode 中会生成一个新的临时 context ,存放where下所有子节点的sql片段(也就是说 IfSqlNode里拿到的context 与最外面传进来的context不是同一个)

  3. 执行去除前缀后,将临时 context 中的结果拼接到 最外层 context 上

forEach节点也采用了类似的临时context的方式

参考

转载自:https://juejin.cn/post/7243757233665949755
评论
请登录