统一开发框架现已支持使用 jOOQ 编写类型安全的 SQL 查询

2025-10-23, 星期四, 18:32

培训

在项目的早期开发阶段(或者在某些项目中,一直持续到合同结束(我不说是谁)),领域模型的变更频率几乎是噩梦级的 —— 实体类的属性在不停增减、字段命名时常调整,数据类型也可能说改就改。对于使用 MyBatis(还有 MyBatis-Plus)的团队来说,IDE 的重构工具虽能照顾到 Java 代码,但对散落在 Mapper 注解或 XML 文件中的 SQL 语句却无能为力,一旦疏忽大意,就只能等到运行时来点惊喜了。(诚然,你可以在 IDEA 中配置 Mapper XML 关联的数据库和 Schema,这两样两边对不上的时候 IDE 可以给你抛出 Error。但是不知怎么的,在笔者的机器上这一配置常常在没人注意的时候偷偷消失)

问题的本质是 SQL 与代码脱节。如果能在编译阶段就发现 SQL 不匹配的问题,把这类低级错误扼杀在摇篮里,岂不是很舒服了?

MyBatis-Plus 的 LambdaQueryWrapper 交出了一份不错的答卷,在面对简单连表场景时也有一些社区的扩展可以使用,不过比 CRUD 再复杂一些时,Lambda 立刻原形毕露,要么写不出来,要么写出极其丑陋的代码。

所以也许我们需要一种写起来像 SQL,编译起来像 Java 的东西,比如 QueryDSL 和 jOOQ,笔者在一番比较后选择了后者(怎么比的忘了)。

jOOQ 根据数据库元数据为整个数据库生成一套对应的 Java 类 —— 每个表是一个类,每个字段是一个常量,类型完全对齐数据库定义,写查询实际上是在用 Java 的类型系统抽象数据库操作。

于是,有这么一段 SQL 查询:

SELECT b.title, a.name
FROM book b
JOIN author a ON b.author_id = a.id
WHERE b.published_in > 2020;

user: ChatGPT,将上述 SQL 查询转换成 jOOQ 语句

assiant: voilà

dslContext.select(BOOK.TITLE, AUTHOR.NAME)
   .from(BOOK)
   .join(AUTHOR).on(BOOK.AUTHOR_ID.eq(AUTHOR.ID))
   .where(BOOK.PUBLISHED_IN.gt(2020))
   .fetch();

因为这是在 Java IDE 里的 Java 代码,所以你有了可编译、可推断的查询语法树,编译期检查、类型推断和自动补全就都有了。

那要怎样集成到项目中呢

最新版本的统一开发框架已经在 codegenerate module 中集成 jOOQ。启动 org.jeecg.codegenerate.JOOQCodeGenerateRunner 将会使用主 Application local 配置中的 JDBC 信息连接数据库,生成 Java 代码并放在 system module 的 src/main/java/org/jooq/codegen 目录下。

由于这个 Runner 继承了主 Application 的配置,如果修改了实体类属性,在启动过程中会先由 Elias 更新数据库 schema,再轮到 jOOQ 根据新的 schema 生成 Java 类,真是「好上加好」。

业务 module 中已经集成了 org.springframework.boot:spring-boot-starter-jooq ,因此代码中可以直接注入 org.jooq.DSLContext 使用,更多用法也可以参阅相关文档。

使用 jOOQ

说实在的,这一章没什么好写的,因为你总归是要知道 SQL 是怎么写的,然后把剩下的问题抛给 ChatGPT / DeepSeek 就行。

这个例子中展示了嵌套查询和可选条件,不过由于 POJO 中有一些自定义类型的属性,因此这里只是构造了查询,并没有实际执行。

SelectConditionStep<?> query = dslContext
    .selectFrom(TBL_INSTRUCTION)
    .where(
        TBL_INSTRUCTION.INSTRUCTOR_ID.eq(currentUser.getId())
        .andExists(
            DSL.selectOne()
                .from(TBL_INSTRUCTION_REPLY)
                .where(TBL_INSTRUCTION_REPLY.INSTRUCTION_ID.eq(TBL_INSTRUCTION.ID))
        .and(TBL_INSTRUCTION_REPLY.MARK_AS_READ.eq(false)))
        .and(Objects.nonNull(scenario)
             ? TBL_INSTRUCTION.SCENARIO.eq(scenario.getValue()) 
             : DSL.noCondition())
    );

当然,LLM 有时候会为你“优化”一下,例如把内嵌子查询变为连表查询,至于哪一种性能更好,还是要按项目的实际情况来。

利用为 MyBatis 编写的代码

jOOQ 返回特定的几种结构用于描述数据库查询结果,如果你是在做聚类统计,从 Map 里取数据自然是没问题的;如果是要取出特定的几条记录来,POJO 类只有基本类型成员的时候也可以“映射”到对应的 Class;不过如果使用了枚举,或是要做一些类型转换,就比较麻烦了。

好在设计 POJO 类的时候这些工作肯定都已经做过了,比如枚举一般实现了 com.baomidou.mybatisplus.annotation.IEnum 接口,自定义类型通常也定义了 TypeHandler。如果有办法让 MyBatis(MyBatis-Plus)识别就好了。

其中一种方案是让 jOOQ 来检查和生成正确的 SQL,然后交给 MyBatis 执行。注意在这个阶段还没什么防止 SQL 注入的工作展开,所以不要生成完整的 SQL 交给 MyBatis 执行,而是使用参数化的方法。

return instructionMapper.execute(
    query.getSQL(ParamType.INDEXED),
    query.getBindValues().toArray());

io.xasz.project.wulianronghe.xietongyingyong.domain.instruction.InstructionMapper#execute 实现如下,SelectProvider 注解声明要使用 org.jooq.JooqSqlProvider 将 jOOQ 生成的 SQL 处理成 MyBatis 可以识别的样式:

@Mapper
public interface InstructionMapper extends BaseMapper<Instruction> {
    @SelectProvider(type = JooqSqlProvider.class, method = "execute")
    List<Instruction> execute(@Param("sql") String sql, @Param("params") Object[] params);
    ...