写 Java 后端这些年,和 MyBatis-Plus 打交道的时间占了很大比重,QueryWrapper 用顺手了确实爽,但用多了就发现一个问题:每个查询接口里,都在重复写一堆字段判空、拼接条件、设置 eq、like 的模板代码。尤其是一个列表查询接口,往往带着五六七八个可筛选条件,光构造 wrapper 的代码就占掉半个方法。这个痛点催生了我这个小工具——QueryWrapperGenerator:基于注解配置实体类,自动解析出查询条件并生成查询包装器。简单说,就是给实体类字段标上注解,告诉生成器“这个字段在查询时是等值匹配还是模糊匹配”“什么时候忽略、什么时候参与”,然后调用一个方法,QueryWrapper 就出来了。适合正在用 MyBatis-Plus、又受够了手写 wrapper 条件堆砌的朋友。今天我把这套思路、注解设计、核心实现和实际接入过程完整拆开讲,代码可直接参考改造。
1. 为什么会有这个工具:手写 QueryWrapper 的痛点迁移
1.1 每个查询接口都在重复造轮子
先还原一个很常见的列表查询接口场景。假设我们要按用户姓名、手机号、年龄区间、状态、创建时间区间这几个条件筛选用户,用 MyBatis-Plus 通常会这么写:
java复制public Page<User> queryUserPage(UserQuery query, Page<User> page) {
LambdaQueryWrapper<User> wrapper = Wrappers.lambdaQuery();
if (StringUtils.hasText(query.getName())) {
wrapper.like(StringUtils.hasText(query.getName()), User::getName, query.getName());
}
if (StringUtils.hasText(query.getPhone())) {
wrapper.eq(StringUtils.hasText(query.getPhone()), User::getPhone, query.getPhone());
}
if (query.getAgeStart() != null) {
wrapper.ge(User::getAge, query.getAgeStart());
}
if (query.getAgeEnd() != null) {
wrapper.le(User::getAge, query.getAgeEnd());
}
if (query.getStatus() != null) {
wrapper.eq(User::getStatus, query.getStatus());
}
if (query.getCreateStart() != null) {
wrapper.ge(User::getCreateTime, query.getCreateStart());
}
if (query.getCreateEnd() != null) {
wrapper.le(User::getCreateTime, query.getCreateEnd());
}
return userMapper.selectPage(page, wrapper);
}
这段代码有什么问题?看起来逻辑清晰,但问题恰恰在于“逻辑清晰”的代价太高:
- 每个可筛选字段都要先判空、再选方法、再传列引用和值,三行起步。
- 字段一多,方法长度肉眼可见地膨胀,而且这种膨胀是没有信息量的。
- 如果十几个查询接口每个都这么写,复制粘贴后的修改成本很惊人,漏判一个空,bug 就溜进来了。
- 更难受的是,当你需要给字段加上一个新条件类型(比如从
eq改成like),得在所有写过的接口里逐一找出来改。
这种代码大量存在,本质上是在用“手写”的方式完成一件极其规整、模式化的任务:把实体类的一个字段,根据注解或约定,映射到 QueryWrapper 的一个条件片段上。既然是规整的、可枚举的映射,那就完全可以用一个生成器来解决。
1.2 从“条件对象”到“注解实体”的思路转变
有些人会做一个 UserCondition 类,里面有 name、phone、ageStart、ageEnd 这些字段,然后在 Service 里手动挨个判断和拼装。这样其实问题也还在,只是把 wrapper.eq(...) 搬到了另一种形态里,判断逻辑并没有消失。
我最初想的是:能不能不写判断?让数据自己告诉我们“要不要参与查询”?在 Java 生态里,注解是最自然的一种“元数据声明”方式。给字段打上 @QueryField,声明它的匹配规则和忽略策略,剩下的事情交给生成器。这样实体类本身就成为了查询条件的载体,而不用额外维护一个条件类,也不用在 Service 里手工搬运。
所以核心转变是:把“过程式”的条件构造,变成“声明式”的字段元数据描述。你不再关心 if 和 wrapper.eq(),只关心“这个字段在查询中应该扮演什么角色”,生成器负责把这些角色翻译成 SQL 条件。
这个思路用在 MyBatis-Plus 上非常合适,因为 QueryWrapper / LambdaQueryWrapper 本身就是一套可编程的条件 DSL。我们要做的,只是在这套 DSL 之上再加一层更靠近业务语义的“注解 DSL”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 注解体系设计:用最小的标记成本完成条件声明
2.1 核心注解定义:@QueryField
整个生成器的入口是一个自定义注解,我命名为 @QueryField。它负责描述一个字段作为查询条件时的行为。第一版的核心定义如下:
java复制@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface QueryField {
/**
* 匹配类型,默认等值匹配
*/
MatchType value() default MatchType.EQ;
/**
* 数据库列名,默认不填,使用实体字段的驼峰转下划线
*/
String column() default "";
/**
* 是否允许为空时也拼接条件。
* 默认 false,即值为 null 时直接忽略该条件。
*/
boolean allowNull() default false;
/**
* 条件组名,用于处理 OR 组合,比如 "groupOr"。
*/
String group() default "";
}
MatchType 是一个枚举,涵盖了日常查询几乎所有的基础条件类型:
java复制public enum MatchType {
EQ, // 等于
NE, // 不等于
LIKE, // 模糊匹配,%value%
LIKE_LEFT, // 左模糊,value%
LIKE_RIGHT, // 右模糊,%value
GT, // 大于
GE, // 大于等于
LT, // 小于
LE, // 小于等于
IN, // IN 集合
NOT_IN, // NOT IN 集合
BETWEEN, // BETWEEN 两个值
IS_NULL, // IS NULL
NOT_NULL // IS NOT NULL
}
实际使用的时候,一个查询 DTO 大概长这样:
java复制public class UserQuery {
private String name;
private String phone;
@QueryField(MatchType.GE)
private Integer ageStart;
@QueryField(MatchType.LE)
private Integer ageEnd;
@QueryField(MatchType.EQ)
private Integer status;
@QueryField(MatchType.BETWEEN)
private LocalDateTime[] createTimeRange;
// getter/setter 省略
}
字段名上没有任何 if,生成器看到 ageStart 非空,就会自动调用 ge("age", value)。如果 ageStart 为 null,则直接跳过。这样 Service 里那一段段条件判断就消失了。
设计这个注解时我有三条硬性约束:一是注解不能太啰嗦,一个字段最多只标一个 @QueryField 就能说清楚查询方式;二是不把表和实体字段的映射关系重复塞进来,MyBatis-Plus 已经用 @TableField 管了,这里只需要字段与列的对应关系即可;三是条件解析规则要可预期,不能有“这个字段明明有值却不生效”的黑魔法。
2.2 注解参数取舍:为什么我不建议把表字段名也塞进注解
第一个版本里,我确实在注解里加过 column() 属性,用来手动指定数据库字段名。但用了半个多月后,我把它降级成了一个“兜底”属性,平时基本不用。原因是:在一个成熟项目里,实体类通常都会和数据库表字段做映射(MyBatis-Plus 的驼峰转换基本够用),如果每个 @QueryField 都要写一次列名,那既繁琐又容易和 @TableField 冲突。
更合理的做法是:以实体类的字段名为主,缺省情况下把驼峰转换成下划线作为数据库列名。如果你确实有非标准映射,比如某个字段在数据库里叫 user_real_name,而 Java 字段叫 realName,那就用 column 属性指定一次,或者干脆直接用 MyBatis-Plus 的 @TableField("user_real_name"),生成器在解析时优先读取这个注解,拿不到再用字段名转换。
同理,allowNull 这个参数也很少直接设成 true。因为业务场景里,需要显式查询 IS NULL 的字段非常少。但既然要做通用工具,这种“反默认”的能力得留个口子。像删除标记、逻辑删除字段这类特殊字段,可能会需要 “查那些没有设置时间的记录”,这时 allowNull=true 就能派上用场——告诉生成器:别因为值是 null 就跳过,直接拼一个 IS NULL 给我。
这个小设计的核心原则是:注解最好保持“零配置即可用”的默认状态,只有确实需要特殊处理时,才去翻开高级选项。 一个注解如果每个字段都要写上个三五项参数,那它从起点就已经失败了,因为大家宁愿继续手写 wrapper。
3. 核心实现拆解:反射解析注解并动态构建 QueryWrapper
3.1 生成器的整体流程
QueryWrapperGenerator 的主入口是一个静态方法,接收一个查询对象,返回一个构建好的 LambdaQueryWrapper。整体流程走三步:
- 遍历目标实体类的所有字段(含继承的父类字段)。
- 检查字段上是否有
@QueryField注解,没有的直接跳过。 - 读取字段当前值,按规则决定忽略还是拼接条件,并把条件片段应用到
LambdaQueryWrapper上。
第一版实现不复杂,直接反射拿到 Field[],循环处理就行:
java复制public class QueryWrapperGenerator {
public static <T> LambdaQueryWrapper<T> generate(Object query) {
LambdaQueryWrapper<T> wrapper = Wrappers.lambdaQuery();
Class<?> clazz = query.getClass();
// 递归获取所有字段,包括父类字段
List<Field> fields = getAllFields(clazz);
for (Field field : fields) {
QueryField queryField = field.getAnnotation(QueryField.class);
if (queryField == null) {
continue;
}
field.setAccessible(true);
Object value = field.get(query);
// 判断是否忽略条件
if (shouldIgnore(queryField, value)) {
continue;
}
applyCondition(wrapper, queryField, field, value);
}
return wrapper;
}
// ... 具体实现略
}
getAllFields 需要递归向上取父类字段,否则查询 DTO 继承了公共父类时,父类字段的注解就失效了。这个工具类很基础,但很多人会漏掉对父类的处理。
shouldIgnore 的逻辑很关键:如果 allowNull 为 false,那么 null 直接忽略;如果值是 String 且空白字符串,也忽略;如果值是 Collection 或数组且为空,忽略;如果值是 Optional 且当前值为空,忽略。这套规则处理了绝大多数“用户没填条件”的场景。
3.2 复杂条件的实现细节:IN、BETWEEN、嵌套查询
eq、like、ge 这类单值条件很好实现,真正需要细抠的是 IN、BETWEEN 这类多值条件。
IN 条件:如果字段标记为 MatchType.IN,那么值类型一般是一个 Collection 或数组。实现时会判断值非空且非空集合,然后拿到集合里的元素。这里有个细节:如果集合里只有一个元素,in 和 eq 效果一样,但生成器里我没做特殊优化,直接交给 MyBatis-Plus 处理,因为它内部会判断参数大小,单元素时也能正常拼接。
java复制if (MatchType.IN == matchType) {
if (value instanceof Collection<?> collection) {
if (!collection.isEmpty()) {
wrapper.in(toDbColumn(field), collection);
}
}
}
BETWEEN 条件:BETWEEN 需要两个值,于是我用了一个长度为 2 的数组来表示区间。比如 LocalDateTime[] createTimeRange = {start, end};。解析时如果数组为 null 或长度不为 2,直接忽略;如果其中一个端点为 null,就降级成 ge 或 le,避免 SQL 里出现 BETWEEN NULL AND ? 这种错误。
java复制if (MatchType.BETWEEN == matchType) {
if (value instanceof Object[] array && array.length == 2) {
Object start = array[0];
Object end = array[1];
if (start != null && end != null) {
wrapper.between(column, start, end);
} else if (start != null) {
wrapper.ge(column, start);
} else if (end != null) {
wrapper.le(column, end);
}
}
}
这种“能精确查就精确查,只能半区就半区查”的降级策略,是实际业务里特别常见的诉求。如果数组里一个值都没有,整个条件就不参与查询。
嵌套查询和 OR 条件:严格来说这不是 @QueryField 单字段能解决的,因为 OR 涉及多个字段之间的逻辑关系。我在第一版里没有做复杂的嵌套支持,而是提供了一个 group() 参数。当多个注解字段使用同一个 group 值时,生成器会把它们放到同一组,最终按“组内 OR、组间 AND”的方式拼接。这个设计我们下一部分专门展开,因为它是整个生成器最容易翻车的点。
3.3 空值与默认值策略
前面提到 shouldIgnore,它是生成器的“过滤网”,决定哪些条件该被忽略。但这里有个容易忽略的边界:有的场景里,null 不是“没填”,而是“故意查空”。
比如用户要查“没有手机号的账户”,前端传来的筛选条件是 phone=null,这本来是一个合法的 IS NULL 查询。但如果生成器默认忽略 null 字段,这个查询就永远无法通过注解表达。所以我才加了 allowNull 参数。当 allowNull=true 时,shouldIgnore 会直接放行,然后根据匹配类型生成对应条件:
java复制private static boolean shouldIgnore(QueryField queryField, Object value) {
if (queryField.allowNull()) {
return false;
}
if (value == null) {
return true;
}
if (value instanceof String str && str.isBlank()) {
return true;
}
if (value instanceof Collection<?> collection && collection.isEmpty()) {
return true;
}
if (value instanceof Object[] array && array.length == 0) {
return true;
}
return false;
}
此时如果匹配类型是 IS_NULL,就会拼 wrapper.isNull(column);如果匹配类型是 EQ,就会拼 wrapper.eq(column, null)。这里有一点要提醒:MyBatis-Plus 的 eq(column, null) 生成的 SQL 是 column = ? 且参数为 null,很多数据库不会把它当成 IS NULL 查询。所以如果你真想查空值,建议把匹配类型显式写成 IS_NULL,而不是 EQ。这也是我为什么要把 IS_NULL / NOT_NULL 单独做成枚举值的原因。
对于默认值,我坚持“不聪明”的原则:生成器不做任何值转换,不改写传入的值。比如状态字段,默认值为 0,业务上 0 是有效状态,但 Java 的 Integer 如果初始值是 0,传给生成器时会因为“非 null”而参与查询。这其实是正确行为,因为 0 本身就是用户可能筛选的条件。如果你希望默认值不参与查询,那应该在做 DTO 赋值时就把默认值设成 null,或者用包装类型而不是基本类型。
4. 实战接入:从实体类到 Mapper 层的一体化改造
4.1 实体类应不应该复用为查询条件类?
很多同学第一反应是直接用实体类(比如 User)接收查询参数,然后在上面标 @QueryField。这样省事,但我不推荐把业务实体和查询DTO混在一起。原因有三:
- 实体类往往承载着 ORM 映射、业务校验、序列化多种职责,加入一堆查询标注后职责边界会模糊。
- 列表查询通常只涉及部分字段,如果用整个实体类,前端可能会多传一些你不想开放的筛选条件,产生越权查询风险。
- 查询条件经常需要一些“额外形状”的字段,最典型的就是
ageStart、ageEnd这种区间拆分,或者createTimeRange这种数组,它们是查询专用的,并不存在于实体类。
所以建议的姿势是:创建一个 XxxQuery DTO,继承实体类,在 DTO 里只增加查询相关字段,并对查询字段标记注解。继承的好处是 MyBatis-Plus 的 Lambda 列引用可以直接用,DTO 里还能直接用实体字段名。例如前面的 UserQuery,我让它继承 User,然后只加了 ageStart、ageEnd、createTimeRange 几个字段。
继承带来的字段解析同样由 getAllFields 处理,父类字段扫描必须包含进去。但要注意:如果父类字段上有 @QueryField,而子类重写了该字段并希望覆盖注解行为,反射拿到的 Field 默认是子类的字段,注解应该配置在子类上。实际开发中这种情况很少,但有个心理准备就好。
4.2 Mapper/Service 层的接入方式
生成器做得再花哨,接入方式如果繁琐,落地阻力就会很大。我提供两种接入方式,项目里按照团队习惯选一种即可。
方式一:在 Service 方法里手动调用生成器
java复制public Page<User> pageUser(UserQuery query, long current, long size) {
Page<User> page = new Page<>(current, size);
LambdaQueryWrapper<User> wrapper = QueryWrapperGenerator.generate(query);
return userMapper.selectPage(page, wrapper);
}
这是最直白的方式,适合改造范围小、团队成员熟悉生成器的场景。一行代码完成以前十几行的条件构造,阅读起来也很清楚。
方式二:提供一个基础 Service 接口,把生成器封装进通用方法
如果项目里已有的列表查询接口比较多,可以定义一个带泛型的 PageQueryService 基类。但 MyBatis-Plus 已经提供了 IService,我更倾向于在 ServiceImpl 上做一个默认实现:
java复制public interface BasePageService<T> extends IService<T> {
default Page<T> pageByQuery(Object query, long current, long size) {
Page<T> page = new Page<>(current, size);
LambdaQueryWrapper<T> wrapper = QueryWrapperGenerator.generate(query);
return page(page, wrapper);
}
}
这样接入时,业务 Service 只需要继承 BasePageService<User>,接口里直接调用 pageByQuery(query, 1, 10) 即可。改动成本几乎没有,但前提是团队统一使用这个基类。
这里顺便说一句:生成器只负责构造 QueryWrapper,不负责分页。分页仍由 MyBatis-Plus 的 Page 参数控制,二者天然解耦,这点让生成器的定位非常干净。
4.3 一个完整的用户查询接口示例
我用一个具体例子展示从 Controller 到 Service 的完整链路。前端传参是一个 JSON 对象,包含 name、status、ageStart、ageEnd、createTimeRange:
java复制@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
@PostMapping("/page")
public Result<Page<User>> page(@RequestBody UserQuery query,
@RequestParam(defaultValue = "1") long current,
@RequestParam(defaultValue = "10") long size) {
return Result.ok(userService.pageByQuery(query, current, size));
}
}
对应 UserQuery 定义:
java复制@Data
@EqualsAndHashCode(callSuper = true)
public class UserQuery extends User {
@QueryField(MatchType.LIKE)
private String name;
@QueryField(MatchType.EQ)
private Integer status;
@QueryField(MatchType.GE)
private Integer ageStart;
@QueryField(MatchType.LE)
private Integer ageEnd;
@QueryField(MatchType.BETWEEN)
private LocalDateTime[] createTimeRange;
}
我故意没在父类 User 的字段上标注解,而是在子类重写了 name 和 status 两个字段并加上注解。这是另一种思路:实体类保持纯净,查询字段集中到 DTO 中声明。User 中如果有 id、phone,不在 DTO 里重写,它们就不会被当作查询条件,从而天然过滤掉非预期筛选。
生成器解析后,如果前端传了 name="张"、status=1、ageStart=20、ageEnd=30、createTimeRange=["2024-01-01", "2024-12-31"],实际生成的 SQL 等价于:
sql复制WHERE name LIKE CONCAT('%', '张', '%')
AND status = 1
AND age >= 20
AND age <= 30
AND create_time BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59'
如果前端没传 status,SQL 里就没有 AND status = ?。整个条件构造过程完全由生成器接管,我的 Service 层从“几十行 if + wrapper”缩减到一行调用。这在实际项目里带来的收益是立竿见影的,尤其当查询接口多到几十个时,能明显感觉代码量在下降。
5. 边界情况与扩展建议:让生成器经得起复杂业务考验
5.1 复杂条件组合:AND/OR 分组怎么设计
最让我纠结的功能点就是条件分组。普通列表查询都是“AND 连接”,但一旦需要实现“名字叫张三 OR 手机号是 138xxxx”这种查询,简单的每字段独立 eq/like 就表达不了了。
我最初的想法是照搬 MyBatis-Plus 的 and(consumer) 和 or(consumer),在注解里加一个 group 属性,相同 group 值的字段自动放进一个 or 条件块里。但真正实现后发现一个麻烦:LambdaQueryWrapper 的 and / or 方法接受一个 Consumer<Param>,我们无法直接拿到“里面生成了哪些条件”的回溯信息,因为条件拼接过程中,wrapper.and(w -> ...) 里面的 w 是临时包装器,我们可以在临时包装器上应用条件,但每个字段的条件必须先按组收集。
我的具体做法是分两步:
- 先遍历字段,把带相同
group值的条件收集到一个组内。 - 在真正拼接 wrapper 时,对每一个组调用一次
wrapper.and(w -> { group.apply(w); });不带group的条件则直接wrapper.and()/ 默认拼接。
对于“组内 OR”逻辑,组内所有条件用 or() 连接。比如有两个字段都标了 group="nameOrPhone",最终会生成:
sql复制AND (name LIKE CONCAT('%', ?, '%') OR phone = ?)
组和组之间默认是 AND。这种设计基本覆盖了 90% 的 OR 查询需求。但要注意:它不支持多层括号嵌套,比如 (A OR B) AND (C OR (D AND E))。这种复杂逻辑不适合用注解去表达,强上只会让注解体系变得极其难懂。我的建议是:生成器覆盖简单和中等复杂度的条件,碰到多层嵌套分组,就退回到手写 wrapper,不要强行用工具。工具是给人用的,不是用来制造新的认知负担的。
5.2 排序与分页支持
查询接口永远离不开排序。@QueryField 只负责条件,那排序怎么办?
我的方案是单独提供一个 @QueryOrder 注解,加在字段上,用于声明“该字段可以参与排序”。但生成器主方法不接收“排序字段”和“排序方向”这两个动态参数,实在有点别扭。更合理的做法是让查询 DTO 继承一个基础类,里面带上 sortField 和 sortOrder 两个字段:
java复制public class BaseQuery {
private String sortField;
private Boolean asc;
}
生成器在构造完条件后,检查 sortField 是否存在于实体类字段中(防止任意字段排序导致 SQL 注入或非法映射),存在才调用 wrapper.orderByAsc/orderByDesc。这里需要特别做一次字段白名单校验:排序字段必须是实体类真实拥有的字段,不能直接拼接前端传上来的字符串,否则等于放任 SQL 注入。MyBatis-Plus 的 wrapper.orderBy 支持列名映射,但如果用 orderBy("name") 这种字符串方式,一定要做白名单校验。
分页我前面说了不走生成器,由 Page 对象控制,这里不再赘述。
5.3 性能与安全:防止 SQL 注入与反射开销
性能方面,最直接的担忧是反射效率。其实在 Spring Boot 项目里,一次查询请求中反射扫描字段的开销微乎其微,远小于一次数据库 IO。但如果查询量很大、对象构造频繁,还是建议做一层缓存。
我的做法是用一个 ConcurrentHashMap<Class<?>, List<Field>> 缓存“已解析过注解的字段列表”,同时把每个字段的 setAccessible(true) 也缓存起来。这样第二次调用时不需要重新遍历类层级和注解解析,只做字段值读取,性能几乎和手写 wrapper 没有差别。
java复制private static final Map<Class<?>, List<Field>> CACHE = new ConcurrentHashMap<>();
private static List<Field> getAllFields(Class<?> clazz) {
return CACHE.computeIfAbsent(clazz, c -> {
List<Field> fields = new ArrayList<>();
Class<?> current = c;
while (current != null && current != Object.class) {
fields.addAll(Arrays.asList(current.getDeclaredFields()));
current = current.getSuperclass();
}
return fields;
});
}
安全和注入方面,这个生成器天然安全,因为所有条件值都是通过 wrapper 的参数占位符绑定的,不是字符串拼接。唯一可能出问题的点是排序字段,我已经在前面强调过白名单校验。另外,不要在注解的 column 属性里直接写恶意的表达式,这个属性虽然是开发人员自己控制的,但代码评审时还是需要注意,不能把用户传参拼进 column。
5.4 我踩过的几个坑
最后分享几个实际使用中踩出来的细节,都是文档里不会写的。
日期区间查询的“23:59:59”问题。很多人使用 LocalDate 类型做 BETWEEN 查询,但数据库里存的是 datetime。如果用户传 createTimeRange = [2024-01-01, 2024-01-31],直接 between 会把 1 月 31 日零点之后的数据全部排除掉。我的解决方式是在生成器里对 LocalDate 做特殊处理:如果是 LocalDate[],把数组第二个元素转成 LocalDateTime 并设置为当天最后一毫秒。这个转换逻辑并不复杂,但漏掉它会导致日期范围少查最后一天。
继承字段的注解覆盖顺序。getDeclaredFields() 只会拿当前类声明的字段,不会拿父类的。如果子类重写了父类字段(比如都叫 status),而重写后没有注解,父类的注解就被“丢掉”了。这个问题特别隐蔽,因为编译不报错,运行结果却和预期不一致。建议在代码规范里明确约定:DTO 中重写父类字段时,必须重新标注 @QueryField,否则生成器不保证行为。
基本类型 int 的默认值 0 陷阱。DTO 的查询字段不要用基本类型,要用包装类型。否则 0 会被当成有效值参与查询,而前端往往想表达的是“不筛选”。这个坑我见太多人踩过,已经在团队规范里直接禁止查询 DTO 用基本类型了。
不要滥用生成器处理大报表。报表类查询往往逻辑复杂,会涉及多表 join、聚合函数、子查询,这时 QueryWrapper 本身就不合适了,更别说注解驱动的生成器。生成器的适用边界是单表 CRUD 和简单连表查询,超出这个范围,我建议老老实实写 XML SQL 或 @Select 注解,不要为了统一而强行套用。
最后再分享一点个人体会
做这个工具的初衷,不是想做一个“银弹”,而是想把 Java 后端开发里最没技术含量、又最消耗精力的重复劳动干掉。实际跑了两三个月后,团队里查单表的接口代码明显清爽了,新同学上手也很快。但过程中我也深刻体会到,工具的价值不在于它多智能,而在于使用边界多清晰。注解能表达的查询结构上限就在那里,与其费尽心思去支持各种复杂嵌套,不如让它在自己该发挥作用的地方脚踏实地。
如果你也在用 MyBatis-Plus,并且正被大段的 wrapper 代码困扰,可以照着我这个思路撸一个轻量版。不用一上来就把所有特性做完,先实现 eq、like、IN、BETWEEN,跑通两个接口后再慢慢加分组和排序。这样你会对“到底哪些逻辑该交给注解,哪些该留给手写”有更真实的体感。等这个工具稳定了,你可能会发现,原来省下来的时间,足够好好读一读那些复杂报表 SQL 了。
