MyBatis-Plus 的 apply 方法是一个强大的功能,允许你在 QueryWrapperUpdateWrapper 生成的 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 参数,只有当 conditiontrue 时,才会应用这个 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. 常见错误

  1. SQL 注入风险 (最严重)

    • 错误wrapper.apply("name = '" + userInput + "'"); 直接拼接用户输入。
    • 后果:攻击者可以通过输入 ' OR '1'='1 等构造恶意 SQL,导致数据泄露或破坏。
    • 解决绝对避免字符串拼接。始终使用 {} 占位符和 formatArgs 参数。MyBatis-Plus 会通过 PreparedStatement 的参数化查询来防止注入。
  2. 占位符 {} 使用错误

    • 错误wrapper.apply("age > ?", minAge); 使用了 ? 占位符。
    • 后果? 不会被 MyBatis-Plus 的 apply 机制识别和替换,导致 SQL 语法错误或参数未绑定。
    • 解决:必须使用 {} 作为占位符,如 wrapper.apply("age > {0}", minAge);
  3. @{col} 使用错误

    • 错误wrapper.apply("SELECT {@name} FROM user");apply 中尝试写完整的 SELECT
    • 后果apply 是添加到 WHERE 子句的,不能用于构建 SELECTFROM
    • 解决@{col} 只能在 apply 的 SQL 片段中用来引用字段名,且该片段应是 WHERE 条件的一部分。例如 wrapper.apply("@{name} LIKE {0}", "%john%");
  4. 忽略数据库方言差异

    • 错误:在 PostgreSQL 项目中使用 DATE_FORMAT(create_time, '%Y-%m')
    • 后果DATE_FORMAT 是 MySQL 函数,PostgreSQL 中不存在,导致 SQL 错误。
    • 解决:确保 apply 中的 SQL 片段与目标数据库兼容。考虑使用数据库无关的逻辑或在应用层处理。
  5. apply 位置混淆

    • 错误:认为 apply 可以用于 ORDER BYGROUP BY
    • 澄清apply 主要用于 WHERE 子句。对于 ORDER BY 应使用 orderByAsc/Desc,对于 GROUP BY 应使用 groupBy

4. 注意事项

  1. SQL 注入是首要风险apply 方法是 MyBatis-Plus 中 SQL 注入风险最高的地方。永远不要将用户输入直接拼接到 applySql 字符串中。必须使用 {} 占位符和 formatArgs
  2. 数据库兼容性apply 中的 SQL 片段通常是数据库特定的(如函数、语法)。使用它会降低代码的数据库可移植性。
  3. 可读性与维护性:过度使用 apply 会使代码变得难以阅读和维护,因为它将 SQL 逻辑分散到了 Java 代码中。优先考虑使用 MyBatis-Plus 的原生方法或在 Mapper XML 中编写复杂 SQL。
  4. @{col} 的局限性@{col} 只能替换字段名,不能用于值或表名。它依赖于 @TableField 注解的 value 属性。
  5. last 方法的副作用last 会直接追加到 SQL 末尾,可能破坏分页插件的功能(因为它会把 LIMIT 放在 ORDER BY 之前),并且同一个 Wrapper 实例多次调用 last 会覆盖之前的设置。
  6. 性能:复杂的 apply 片段(如子查询、复杂函数)可能影响数据库查询性能,需要关注执行计划。

5. 使用技巧

  1. 优先使用原生方法:检查是否可以用 eq, like, in, between 等方法实现,避免过早使用 apply
  2. 封装复杂逻辑:如果某个 apply 片段很复杂或常用,可以将其封装成一个静态方法或工具类方法。
  3. 利用 condition 参数:使用 apply(condition, ...) 可以优雅地实现条件化应用 SQL 片段,避免在 Java 代码中写 if 判断。
  4. @{col} 保证映射:当需要引用字段名且担心 @TableField 映射改变时,使用 @{col} 可以自动适配。
  5. last 用于特殊场景last("FOR UPDATE") 用于行级锁,last("USE INDEX (idx_name)") 用于强制使用索引(MySQL),但需谨慎。

6. 最佳实践与性能优化

  1. 最佳实践

    • 最小化使用apply 应作为最后的选择。优先使用 MyBatis-Plus 提供的标准方法、LambdaQueryWrapper 或 Mapper XML。
    • 严格参数化始终使用 {} 占位符和 formatArgs 参数来防止 SQL 注入。对 formatArgs 中的值进行必要的校验和清理。
    • 明确注释:在使用 apply 的地方添加清晰的注释,解释为什么需要它,以及 SQL 片段的意图。
    • 单元测试:对包含 apply 的查询编写单元测试,验证生成的 SQL 是否符合预期,并测试边界情况。
    • 考虑可移植性:如果应用需要支持多种数据库,尽量避免使用数据库特定的函数。如果必须使用,考虑通过配置或策略模式来切换 SQL 片段。
    • 避免在 apply 中写完整 SQLapply 是为了补充 WHERE 条件,不是为了写一个完整的独立 SQL。
  2. 性能优化

    • 索引利用:确保 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 注入,并充分考虑数据库兼容性和代码可维护性。 优先选择更安全、更标准的替代方案。