MyBatis-Plus 的 apply
方法是一个强大的功能,允许你在 QueryWrapper
或 UpdateWrapper
生成的 SQL 语句中直接嵌入自定义的 SQL 片段。它提供了极大的灵活性,但也伴随着 SQL 注入的风险,需要谨慎使用。
1. 核心概念
- 目的:在 MyBatis-Plus 自动生成的 SQL 语句中(通常在
WHERE
子句内),插入用户自定义的、MyBatis-Plus 原生方法无法覆盖的 SQL 片段。 - 核心思想:提供一个“逃生舱口”(escape hatch),用于处理复杂的、特定于数据库的或动态生成的 SQL 逻辑。
- 核心方法:
apply(String applySql, Object... formatArgs)
:这是最常用的方法。applySql
是包含{}
占位符的 SQL 片段,formatArgs
是用于替换占位符的实际参数值。MyBatis-Plus 会使用String.format
风格的占位符{}
。apply(boolean condition, String applySql, Object... formatArgs)
:增加一个condition
参数,只有当condition
为true
时,才会应用这个apply
片段。last(String sql)
:在最终生成的 SQL 语句末尾追加任意 SQL 片段(如LIMIT
,FOR UPDATE
)。虽然功能不同,但常与apply
一起讨论,因为它也涉及直接操作 SQL。
- 作用域:
apply
生成的 SQL 片段会被包裹在()
内,并作为AND
条件的一部分添加到WHERE
子句中(除非是第一个条件,可能作为WHERE
的起始)。
2. 操作步骤 (非常详细)
步骤 1: 准备实体类和 Mapper
确保你已经配置好了实体类 (Entity
) 和 Mapper 接口。
// User.java (实体类)
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("user")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("name")
private String name;
@TableField("age")
private Integer age;
@TableField("email")
private String email;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("status")
private Integer status; // 0: inactive, 1: active
}
// UserMapper.java (Mapper 接口)
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper extends BaseMapper<User> {
// 继承 BaseMapper
}
步骤 2: 创建 QueryWrapper/UpdateWrapper 实例
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
// 用于查询
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
// 用于更新
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
步骤 3: 使用 apply
方法添加自定义 SQL 片段
这是核心步骤,展示 apply
的各种用法。
3.1 基本用法:使用 `
// 场景:查询创建时间在指定月份的用户
String targetMonth = "2023-10"; // 例如,从参数获取
// 使用 apply 插入数据库特定的日期函数
// MySQL 示例: DATE_FORMAT(create_time, '%Y-%m')
queryWrapper.apply("DATE_FORMAT(create_time, '%Y-%m') = {0}", targetMonth);
// 生成的 WHERE 子句片段: AND (DATE_FORMAT(create_time, '%Y-%m') = '2023-10')
// PostgreSQL 示例: TO_CHAR(create_time, 'YYYY-MM')
// queryWrapper.apply("TO_CHAR(create_time, 'YYYY-MM') = {0}", targetMonth);
// 执行查询
List<User> users = userMapper.selectList(queryWrapper);
3.2 使用多个 `
// 场景:查询年龄在某个范围内的用户,使用 BETWEEN
Integer minAge = 18;
Integer maxAge = 60;
// {0} 对应 minAge, {1} 对应 maxAge
queryWrapper.apply("age BETWEEN {0} AND {1}", minAge, maxAge);
// 生成: AND (age BETWEEN 18 AND 60)
3.3 在 apply
中引用实体字段
使用 @col
语法可以在 apply
的 SQL 片段中引用实体类的数据库字段名。这有助于保持字段名映射的一致性。
// 场景:查询 name 字段和 email 字段包含相同关键字的用户 (假设关键字是 'john')
String keyword = "john";
// @{name} 会被替换为数据库中的实际字段名 (如 'name')
// @{email} 会被替换为数据库中的实际字段名 (如 'email')
queryWrapper.apply("({@name} LIKE '%{0}%' OR @{email} LIKE '%{0}%')", keyword);
// 生成 (假设字段名是 name/email): AND ((name LIKE '%john%') OR (email LIKE '%john%'))
// 注意:@{col} 不能用于占位符 {0} 的位置,只能用于字段名。
3.4 结合条件使用 apply(condition, ...)
// 场景:只有当某个条件满足时,才添加复杂的日期过滤
boolean needDateFilter = true; // 根据业务逻辑判断
String targetYear = "2023";
// 只有 needDateFilter 为 true 时,才应用这个条件
queryWrapper.apply(needDateFilter, "YEAR(create_time) = {0}", targetYear);
// 如果 needDateFilter 是 false,这个 apply 不会生成任何 SQL。
3.5 在 UpdateWrapper
中使用 apply
// 场景:更新时,将 status 字段设置为基于当前状态的计算值
// 例如:将 status 从 0 (inactive) 翻转为 1 (active),或从 1 翻转为 0
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
// 使用 apply 在 SET 子句中进行计算
// CASE WHEN status = 0 THEN 1 ELSE 0 END
updateWrapper.apply("status = CASE WHEN status = 0 THEN 1 ELSE 0 END");
// 可以结合其他条件
updateWrapper.eq("id", 123); // WHERE id = 123
// 执行更新
int updatedRows = userMapper.update(null, updateWrapper);
// 生成的 SQL: UPDATE user SET status = CASE WHEN status = 0 THEN 1 ELSE 0 END WHERE id = 123
3.6 使用 last
方法 (相关但不同)
// 场景:在查询末尾添加 LIMIT (不推荐用于分页,应使用分页插件)
queryWrapper.eq("status", 1); // WHERE status = 1
queryWrapper.last("LIMIT 1"); // 在 SQL 末尾追加 LIMIT 1
// 生成: SELECT * FROM user WHERE status = 1 LIMIT 1
// 注意:last 会覆盖之前可能存在的 last,且不支持分页插件。
步骤 4: 执行操作
使用 Mapper 的方法执行带有 apply
片段的查询或更新。
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public List<User> getUsersByCustomDate(String yearMonth) {
QueryWrapper<User> wrapper = new QueryWrapper<>();
// 使用 apply 处理日期格式
wrapper.apply("DATE_FORMAT(create_time, '%Y-%m') = {0}", yearMonth);
return userMapper.selectList(wrapper);
}
public boolean toggleUserStatus(Long userId) {
UpdateWrapper<User> wrapper = new UpdateWrapper<>();
// 使用 apply 进行状态翻转
wrapper.apply("status = CASE WHEN status = 0 THEN 1 ELSE 0 END");
wrapper.eq("id", userId);
return userMapper.update(null, wrapper) > 0;
}
}
3. 常见错误
SQL 注入风险 (最严重):
- 错误:
wrapper.apply("name = '" + userInput + "'");
直接拼接用户输入。 - 后果:攻击者可以通过输入
' OR '1'='1
等构造恶意 SQL,导致数据泄露或破坏。 - 解决:绝对避免字符串拼接。始终使用
{}
占位符和formatArgs
参数。MyBatis-Plus 会通过 PreparedStatement 的参数化查询来防止注入。
- 错误:
占位符
{}
使用错误:- 错误:
wrapper.apply("age > ?", minAge);
使用了?
占位符。 - 后果:
?
不会被 MyBatis-Plus 的apply
机制识别和替换,导致 SQL 语法错误或参数未绑定。 - 解决:必须使用
{}
作为占位符,如wrapper.apply("age > {0}", minAge);
。
- 错误:
@{col}
使用错误:- 错误:
wrapper.apply("SELECT {@name} FROM user");
在apply
中尝试写完整的SELECT
。 - 后果:
apply
是添加到WHERE
子句的,不能用于构建SELECT
或FROM
。 - 解决:
@{col}
只能在apply
的 SQL 片段中用来引用字段名,且该片段应是WHERE
条件的一部分。例如wrapper.apply("@{name} LIKE {0}", "%john%");
。
- 错误:
忽略数据库方言差异:
- 错误:在 PostgreSQL 项目中使用
DATE_FORMAT(create_time, '%Y-%m')
。 - 后果:
DATE_FORMAT
是 MySQL 函数,PostgreSQL 中不存在,导致 SQL 错误。 - 解决:确保
apply
中的 SQL 片段与目标数据库兼容。考虑使用数据库无关的逻辑或在应用层处理。
- 错误:在 PostgreSQL 项目中使用
apply
位置混淆:- 错误:认为
apply
可以用于ORDER BY
或GROUP BY
。 - 澄清:
apply
主要用于WHERE
子句。对于ORDER BY
应使用orderByAsc/Desc
,对于GROUP BY
应使用groupBy
。
- 错误:认为
4. 注意事项
- SQL 注入是首要风险:
apply
方法是 MyBatis-Plus 中 SQL 注入风险最高的地方。永远不要将用户输入直接拼接到applySql
字符串中。必须使用{}
占位符和formatArgs
。 - 数据库兼容性:
apply
中的 SQL 片段通常是数据库特定的(如函数、语法)。使用它会降低代码的数据库可移植性。 - 可读性与维护性:过度使用
apply
会使代码变得难以阅读和维护,因为它将 SQL 逻辑分散到了 Java 代码中。优先考虑使用 MyBatis-Plus 的原生方法或在 Mapper XML 中编写复杂 SQL。 @{col}
的局限性:@{col}
只能替换字段名,不能用于值或表名。它依赖于@TableField
注解的value
属性。last
方法的副作用:last
会直接追加到 SQL 末尾,可能破坏分页插件的功能(因为它会把LIMIT
放在ORDER BY
之前),并且同一个Wrapper
实例多次调用last
会覆盖之前的设置。- 性能:复杂的
apply
片段(如子查询、复杂函数)可能影响数据库查询性能,需要关注执行计划。
5. 使用技巧
- 优先使用原生方法:检查是否可以用
eq
,like
,in
,between
等方法实现,避免过早使用apply
。 - 封装复杂逻辑:如果某个
apply
片段很复杂或常用,可以将其封装成一个静态方法或工具类方法。 - 利用
condition
参数:使用apply(condition, ...)
可以优雅地实现条件化应用 SQL 片段,避免在 Java 代码中写if
判断。 @{col}
保证映射:当需要引用字段名且担心@TableField
映射改变时,使用@{col}
可以自动适配。last
用于特殊场景:last("FOR UPDATE")
用于行级锁,last("USE INDEX (idx_name)")
用于强制使用索引(MySQL),但需谨慎。
6. 最佳实践与性能优化
最佳实践:
- 最小化使用:
apply
应作为最后的选择。优先使用 MyBatis-Plus 提供的标准方法、LambdaQueryWrapper
或 Mapper XML。 - 严格参数化:始终使用
{}
占位符和formatArgs
参数来防止 SQL 注入。对formatArgs
中的值进行必要的校验和清理。 - 明确注释:在使用
apply
的地方添加清晰的注释,解释为什么需要它,以及 SQL 片段的意图。 - 单元测试:对包含
apply
的查询编写单元测试,验证生成的 SQL 是否符合预期,并测试边界情况。 - 考虑可移植性:如果应用需要支持多种数据库,尽量避免使用数据库特定的函数。如果必须使用,考虑通过配置或策略模式来切换 SQL 片段。
- 避免在
apply
中写完整 SQL:apply
是为了补充WHERE
条件,不是为了写一个完整的独立 SQL。
- 最小化使用:
性能优化:
- 索引利用:确保
apply
片段中的条件(如DATE_FORMAT(create_time, '%Y-%m')
)能够有效利用数据库索引。有时需要创建函数索引(如 MySQL 的INDEX ( (DATE_FORMAT(create_time, '%Y-%m')) )
)。 - 避免复杂计算:尽量避免在
WHERE
子句的apply
片段中进行耗时的计算或子查询,这可能导致全表扫描。 - 监控慢查询:启用数据库慢查询日志,监控包含
apply
片段的 SQL 的执行时间,及时发现性能瓶颈。 last("LIMIT ...")
谨慎使用:虽然可以限制结果集大小,但它可能绕过分页插件的总记录数统计。对于分页,强烈推荐使用 MyBatis-Plus 的分页插件 (Page
对象)。
- 索引利用:确保
总结:apply
方法是 MyBatis-Plus 的“瑞士军刀”,功能强大但需谨慎使用。核心原则是:能不用则不用,必须用时,务必使用 {}
占位符防止 SQL 注入,并充分考虑数据库兼容性和代码可维护性。 优先选择更安全、更标准的替代方案。