Skip to content

基于 Record / POJO 增删改查 ​

在前几篇中,我们已经完成了:

  • tio-boot + jOOQ 纯配置类整合
  • 事务管理
  • Agroal / Druid 数据源整合
  • jOOQ Codegen 强类型升级

到这里,整个基础设施已经齐备。

接下来真正进入日常开发的核心环节:

如何基于 jOOQ 生成的 Table / Record / POJO,写出清晰、强类型、可维护的增删改查代码。

本文将围绕 system_admin 表,系统讲透:

  • Table、Record、POJO 三者分别是什么
  • 基于 Record 的增删改查
  • 基于 POJO 的增删改查
  • Record.store() 的语义
  • saveOrUpdate 的推荐实现方式
  • batchInsert 批量插入
  • fetchOptional 的更安全查询写法
  • 分页查询
  • 条件动态拼装
  • 在 tio-boot 项目中的推荐分层实践

一、先看清生成出来的三个核心对象 ​

jOOQ Codegen 生成后,围绕一张表通常会出现三类对象。

以 system_admin 为例:

  • demo.jooq.gen.tables.SystemAdmin
  • demo.jooq.gen.tables.records.SystemAdminRecord
  • demo.jooq.gen.tables.pojos.SystemAdmin

很多人第一次接触 jOOQ Codegen,会被这三者绕晕。

其实只要抓住一句话就够了:

Table 用来写 SQL,Record 用来表达数据库中的一行,POJO 用来承载数据。


二、表定义类:Table ​

生成类:

java
demo.jooq.gen.tables.SystemAdmin

这个类表示的是:

数据库表本身,以及它的字段定义。

例如:

java
public static final SystemAdmin SYSTEM_ADMIN = new SystemAdmin();

public final TableField<SystemAdminRecord, Integer> ID = ...
public final TableField<SystemAdminRecord, String> LOGIN_NAME = ...
public final TableField<SystemAdminRecord, String> PASSWORD = ...

以后写 SQL 时,就不再写字符串:

java
DSL.table("system_admin")
DSL.field("login_name")

而是直接写:

java
SYSTEM_ADMIN
SYSTEM_ADMIN.LOGIN_NAME
SYSTEM_ADMIN.PASSWORD

这就是 jOOQ Codegen 最核心的价值之一:

  • 表名字段名编译期校验
  • IDE 自动补全
  • 重构友好
  • 减少字符串 SQL 的拼写错误

三、Record:带数据库语义的一行数据 ​

生成类:

java
demo.jooq.gen.tables.records.SystemAdminRecord

这个类继承自:

java
UpdatableRecordImpl<SystemAdminRecord>

这意味着它不是普通 Java Bean,而是:

一个和数据库表绑定的“可持久化记录对象”。

它除了能装数据,还能直接执行数据库行为,例如:

  • insert()
  • update()
  • delete()
  • store()
  • refresh()

例如:

java
SystemAdminRecord record = dsl.newRecord(SYSTEM_ADMIN);
record.setLoginName("admin");
record.setPassword("123456");
record.insert();

这不是单纯给对象赋值,而是已经可以落库。

所以 Record 的语义非常明确:

它表示表中的一行,并且这一行知道如何把自己写回数据库。


四、POJO:纯数据对象 ​

生成类:

java
demo.jooq.gen.tables.pojos.SystemAdmin

POJO 是一个普通 Java 类,只有:

  • 字段
  • getter / setter
  • equals / hashCode / toString

它不具备:

  • insert()
  • update()
  • delete()

也就是说,POJO 的定位是:

纯数据承载对象,不带数据库行为。

它更适合:

  • 作为查询结果返回
  • 作为 Service 层输入输出对象
  • 作为 Controller 返回对象
  • 作为 DAO 与上层之间的数据边界

五、为什么 jOOQ 同时生成 Record 和 POJO ​

很多 ORM 习惯把“实体类”做成万能对象,既承载数据,又负责持久化。

而 jOOQ 没有这样做。

它把职责拆开了:

Record 的职责 ​

  • 贴近数据库
  • 带持久化行为
  • 更适合 DAO 内部使用

POJO 的职责 ​

  • 只负责数据承载
  • 没有副作用
  • 更适合跨层传递

这种设计非常合理,因为它天然鼓励:

DAO 层处理数据库语义,上层处理业务数据。


六、DAO 基础结构 ​

为了兼容前面文档里的事务上下文,这里继续使用统一的 useDsl() 写法。

java
package demo.jooq.dao;

import static demo.jooq.gen.tables.SystemAdmin.SYSTEM_ADMIN;

import org.jooq.DSLContext;

import nexus.io.annotation.Inject;

import demo.jooq.tx.TransactionContext;

public class SystemAdminDao {

  @Inject
  private DSLContext dsl;

  private DSLContext useDsl() {
    DSLContext txDsl = TransactionContext.get();
    return txDsl != null ? txDsl : dsl;
  }
}

之后所有 CRUD 都基于 useDsl() 来执行。

这样可以保证:

  • 非事务场景下使用全局 DSLContext
  • 事务场景下自动切换到当前线程事务内的 DSLContext

七、基于 Record 的 CRUD ​


7.1 新增:newRecord + insert ​

这是最典型的 Record 风格插入。

java
public Integer insertByRecord(String loginName, String password) {
  SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN);
  record.setLoginName(loginName);
  record.setPassword(password);
  record.insert();
  return record.getId();
}

说明 ​

第一步:

java
useDsl().newRecord(SYSTEM_ADMIN)

创建一个和 system_admin 表绑定的 Record。

第二步:

java
record.setLoginName(...)
record.setPassword(...)

给 Record 赋值。

第三步:

java
record.insert();

执行插入。

如果 id 是数据库自增主键,那么插入完成后通常能拿到生成值:

java
record.getId()

7.2 查询单条:返回 Record ​

java
public SystemAdminRecord findRecordById(Integer id) {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOne();
}

这里的:

java
selectFrom(SYSTEM_ADMIN)

表示查询整张表全部字段,并且结果类型就是 SystemAdminRecord。

因为表和记录类型已经由 Codegen 绑定好了。


7.3 查询列表:返回 Record 集合 ​

java
public List<SystemAdminRecord> findAllRecords() {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .orderBy(SYSTEM_ADMIN.ID.asc())
      .fetch();
}

fetch() 返回的是 Result<SystemAdminRecord>,它本身可以当作列表来用。


7.4 更新:先查再改,再 update() ​

java
public boolean updatePasswordByRecord(Integer id, String newPassword) {
  SystemAdminRecord record = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOne();

  if (record == null) {
    return false;
  }

  record.setPassword(newPassword);
  record.update();
  return true;
}

这是典型的“对象式更新”:

  1. 查出 Record
  2. 修改字段
  3. 调用 update()

优点是语义直观,缺点是通常要两次数据库访问:

  • 一次查询
  • 一次更新

7.5 删除:先查 Record,再 delete() ​

java
public boolean deleteByRecord(Integer id) {
  SystemAdminRecord record = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOne();

  if (record == null) {
    return false;
  }

  record.delete();
  return true;
}

这同样是 Record 风格的删除方式。


八、基于 POJO 的 CRUD ​

POJO 更适合作为查询结果和跨层数据对象。


8.1 查询单条:fetchOneInto(POJO.class) ​

java
public demo.jooq.gen.tables.pojos.SystemAdmin findPojoById(Integer id) {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOneInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);
}

jOOQ 会自动把结果映射到生成的 POJO 中。


8.2 查询列表:fetchInto(POJO.class) ​

java
public List<demo.jooq.gen.tables.pojos.SystemAdmin> findAllPojos() {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .orderBy(SYSTEM_ADMIN.ID.asc())
      .fetchInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);
}

这个写法很常用,也很适合作为 DAO 对外返回形式。


8.3 新增:POJO 转 Record 再插入 ​

POJO 自身不能插入数据库,需要先转换成 Record。

推荐写法:

java
public Integer insertByPojo(demo.jooq.gen.tables.pojos.SystemAdmin pojo) {
  SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
  record.insert();
  return record.getId();
}

这里:

java
newRecord(SYSTEM_ADMIN, pojo)

会根据 POJO 的字段值创建一个绑定当前 DSLContext 的 Record,比手工 new SystemAdminRecord(pojo) 更稳妥。


8.4 更新:POJO 驱动的 DSL 更新 ​

对于 POJO 更新,生产里更推荐显式写 update 语句,而不是强依赖 Record 状态。

java
public int updateByPojo(demo.jooq.gen.tables.pojos.SystemAdmin pojo) {
  return useDsl()
      .update(SYSTEM_ADMIN)
      .set(SYSTEM_ADMIN.LOGIN_NAME, pojo.getLoginName())
      .set(SYSTEM_ADMIN.PASSWORD, pojo.getPassword())
      .where(SYSTEM_ADMIN.ID.eq(pojo.getId()))
      .execute();
}

这个写法的优点是:

  • SQL 意图非常清晰
  • 不依赖 Record 的状态判断
  • 更适合生产环境

8.5 删除:通常直接 DSL ​

java
public int deleteById(Integer id) {
  return useDsl()
      .deleteFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .execute();
}

删除场景一般不需要借助 POJO。


九、Record.store() 讲透 ​

Record.store() 是很多人刚接触 jOOQ 时最容易产生误解的地方。

它看起来很方便,因为它似乎在做“自动保存”。

但要真正用好它,必须理解它的语义。


9.1 store() 是什么 ​

store() 的语义是:

根据当前 Record 的状态,决定执行 insert 还是 update。

也就是说,它并不是简单等于 insert(),也不简单等于 update()。

它会结合:

  • Record 是否来自数据库
  • 主键是否存在
  • changed 状态
  • 当前 Record 是否被识别为新记录

来判断执行哪种操作。


9.2 最常见的 store() 用法 ​

新记录:执行插入 ​

java
public Integer saveWithStore(String loginName, String password) {
  SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN);
  record.setLoginName(loginName);
  record.setPassword(password);
  record.store();
  return record.getId();
}

这里通常相当于执行 insert()。


已查询出的记录:执行更新 ​

java
public boolean updateWithStore(Integer id, String newPassword) {
  SystemAdminRecord record = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOne();

  if (record == null) {
    return false;
  }

  record.setPassword(newPassword);
  record.store();
  return true;
}

这里通常相当于执行 update()。


9.3 store() 的优点 ​

优点是代码简洁:

  • 新建时可插入
  • 查询后修改时可更新

对于简单单表操作,非常自然。


9.4 store() 的局限 ​

但 store() 不是万能的,原因在于:

1. 它依赖 Record 状态 ​

如果 Record 不是从 DSLContext 正常创建或查询出来的,而是 detached 状态,store() 的行为就可能不符合预期。

2. 它不一定适合“明确意图”的场景 ​

有些时候,业务就是要强制 insert; 有些时候,业务就是要强制 update。

此时直接写:

java
record.insert();
record.update();

比 store() 更清晰。

3. 对团队可读性有要求 ​

不是所有人一眼都能明白 store() 背后到底会执行什么 SQL。


9.5 对 store() 的建议 ​

推荐原则:

  • 简单单行保存逻辑可以使用 store()
  • 明确只新增时用 insert()
  • 明确只更新时用 update()
  • 对性能和意图要求高时,直接 DSL 语句更清晰

十、saveOrUpdate 的推荐实现 ​

很多项目里都有“有主键就更新,没有主键就新增”的需求。

这类方法常被命名为:

java
saveOrUpdate

但这里一定要注意:

saveOrUpdate 是业务语义,不等于数据库天然提供的 UPSERT。

它至少有两种不同层面的实现方式:

  • 应用层 saveOrUpdate
  • 数据库层 upsert

本文先讲应用层。


10.1 方式一:基于主键判断 ​

这是最常见、最易理解的写法。

java
public Integer saveOrUpdate(demo.jooq.gen.tables.pojos.SystemAdmin pojo) {
  if (pojo.getId() == null) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
    record.insert();
    return record.getId();
  } else {
    useDsl()
        .update(SYSTEM_ADMIN)
        .set(SYSTEM_ADMIN.LOGIN_NAME, pojo.getLoginName())
        .set(SYSTEM_ADMIN.PASSWORD, pojo.getPassword())
        .where(SYSTEM_ADMIN.ID.eq(pojo.getId()))
        .execute();
    return pojo.getId();
  }
}

适用场景 ​

  • 主键自增
  • 有 id 代表更新
  • 无 id 代表新增

这是最推荐作为教程默认写法的方案,因为最清晰。


10.2 方式二:先查是否存在,再决定 insert / update ​

如果更新依据不是主键,而是某个业务唯一键,比如 login_name,则可以这样写:

java
public Integer saveOrUpdateByLoginName(demo.jooq.gen.tables.pojos.SystemAdmin pojo) {
  SystemAdminRecord exist = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.LOGIN_NAME.eq(pojo.getLoginName()))
      .fetchOne();

  if (exist == null) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
    record.insert();
    return record.getId();
  } else {
    exist.setPassword(pojo.getPassword());
    exist.update();
    return exist.getId();
  }
}

适用场景 ​

  • 业务唯一键更新
  • 需要基于已存在记录做差异修改

10.3 saveOrUpdate 和数据库 UPSERT 不是一回事 ​

应用层 saveOrUpdate 的逻辑是:

  • 先判断
  • 再 insert 或 update

数据库层 UPSERT 的逻辑则通常是:

  • 一条 SQL 完成冲突检测和写入

在 PostgreSQL 中通常会写成:

sql
insert into ...
on conflict (...) do update ...

在 jOOQ 中也可以表达,但这属于下一层进阶主题。

因此本文里讲的 saveOrUpdate,更准确地说是:

业务层面的 saveOrUpdate,而不是数据库级原子 upsert。


十一、fetchOptional:更安全的单条查询 ​

很多人写单条查询时会用:

java
fetchOne()

这没问题,但返回值是可空的。

如果想更显式地表达“可能没有结果”,jOOQ 提供了更现代的写法:

java
fetchOptional()

11.1 基本用法 ​

java
public Optional<SystemAdminRecord> findOptionalRecordById(Integer id) {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOptional();
}

这样返回值就是:

java
Optional<SystemAdminRecord>

调用方必须显式处理“有值 / 无值”两种情况。


11.2 映射成 POJO ​

java
public Optional<demo.jooq.gen.tables.pojos.SystemAdmin> findOptionalPojoById(Integer id) {
  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(SYSTEM_ADMIN.ID.eq(id))
      .fetchOptionalInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);
}

这样非常适合 Service 层写法。


11.3 为什么推荐 fetchOptional ​

和 fetchOne() 比起来,它的优点是:

  • 显式表达结果可能不存在
  • 减少空指针风险
  • 调用方处理逻辑更清晰
  • 更适合现代 Java 风格

例如:

java
public String findPassword(Integer id) {
  return findOptionalRecordById(id)
      .map(SystemAdminRecord::getPassword)
      .orElse(null);
}

11.4 fetchOne() 与 fetchOptional() 的区别 ​

两者对“多条结果”的要求本质一样:

  • 预期最多一条
  • 如果查到多条,通常会报错

区别只是:

  • fetchOne() 返回对象或 null
  • fetchOptional() 返回 Optional<T>

所以:

fetchOptional() 不是“忽略唯一性约束”,只是让“空结果”的表达更安全。


十二、分页查询 ​

分页是后台开发最常见的需求之一。

在 jOOQ 里,最直接的分页方式就是:

  • limit
  • offset

12.1 最基础分页 ​

java
public List<demo.jooq.gen.tables.pojos.SystemAdmin> paginate(int pageNo, int pageSize) {
  int offset = (pageNo - 1) * pageSize;

  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .orderBy(SYSTEM_ADMIN.ID.desc())
      .limit(pageSize)
      .offset(offset)
      .fetchInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);
}

说明 ​

  • pageNo 从 1 开始
  • offset = (pageNo - 1) * pageSize

例如:

  • 第 1 页,offset = 0
  • 第 2 页,offset = pageSize

12.2 带总数的分页 ​

只查当前页数据还不够,通常还要总数。

java
public PageResult<demo.jooq.gen.tables.pojos.SystemAdmin> paginateWithTotal(int pageNo, int pageSize) {
  int offset = (pageNo - 1) * pageSize;

  Integer total = useDsl()
      .selectCount()
      .from(SYSTEM_ADMIN)
      .fetchOne(0, int.class);

  List<demo.jooq.gen.tables.pojos.SystemAdmin> list = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .orderBy(SYSTEM_ADMIN.ID.desc())
      .limit(pageSize)
      .offset(offset)
      .fetchInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);

  return new PageResult<>(pageNo, pageSize, total, list);
}

可以定义一个简单分页对象:

java
package demo.jooq.model;

import java.util.List;

public class PageResult<T> {
  private int pageNo;
  private int pageSize;
  private int total;
  private List<T> list;

  public PageResult() {
  }

  public PageResult(int pageNo, int pageSize, int total, List<T> list) {
    this.pageNo = pageNo;
    this.pageSize = pageSize;
    this.total = total;
    this.list = list;
  }

  public int getPageNo() {
    return pageNo;
  }

  public void setPageNo(int pageNo) {
    this.pageNo = pageNo;
  }

  public int getPageSize() {
    return pageSize;
  }

  public void setPageSize(int pageSize) {
    this.pageSize = pageSize;
  }

  public int getTotal() {
    return total;
  }

  public void setTotal(int total) {
    this.total = total;
  }

  public List<T> getList() {
    return list;
  }

  public void setList(List<T> list) {
    this.list = list;
  }
}

12.3 分页查询的注意点 ​

1. 必须有稳定排序 ​

分页一定要配合 orderBy,否则结果不稳定。

推荐:

java
.orderBy(SYSTEM_ADMIN.ID.desc())

2. 深分页要谨慎 ​

如果页码非常深,offset 可能性能变差。

例如:

  • 第 1 万页
  • offset 非常大

这时更推荐“基于游标 / 上次主键”的分页方式,但这是进阶话题。

3. count 和列表查询通常分两条 SQL ​

这是最常见、最清晰的实现方式。


十三、条件动态拼装 ​

这部分是 jOOQ 的强项之一。

相比 MyBatis XML 的动态标签,jOOQ 可以直接用 Java 表达式拼条件,代码更自然。


13.1 最简单的动态条件 ​

假设有一个查询对象:

java
package demo.jooq.model;

public class SystemAdminQuery {
  private Integer id;
  private String loginName;
  private String password;
  private Integer pageNo;
  private Integer pageSize;

  public Integer getId() {
    return id;
  }

  public void setId(Integer id) {
    this.id = id;
  }

  public String getLoginName() {
    return loginName;
  }

  public void setLoginName(String loginName) {
    this.loginName = loginName;
  }

  public String getPassword() {
    return password;
  }

  public void setPassword(String password) {
    this.password = password;
  }

  public Integer getPageNo() {
    return pageNo;
  }

  public void setPageNo(Integer pageNo) {
    this.pageNo = pageNo;
  }

  public Integer getPageSize() {
    return pageSize;
  }

  public void setPageSize(Integer pageSize) {
    this.pageSize = pageSize;
  }
}

然后 DAO 里这样写:

java
import org.jooq.Condition;
import org.jooq.impl.DSL;

public List<demo.jooq.gen.tables.pojos.SystemAdmin> search(SystemAdminQuery query) {
  Condition condition = DSL.trueCondition();

  if (query.getId() != null) {
    condition = condition.and(SYSTEM_ADMIN.ID.eq(query.getId()));
  }

  if (query.getLoginName() != null && !query.getLoginName().isEmpty()) {
    condition = condition.and(SYSTEM_ADMIN.LOGIN_NAME.like("%" + query.getLoginName() + "%"));
  }

  if (query.getPassword() != null && !query.getPassword().isEmpty()) {
    condition = condition.and(SYSTEM_ADMIN.PASSWORD.eq(query.getPassword()));
  }

  return useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(condition)
      .orderBy(SYSTEM_ADMIN.ID.desc())
      .fetchInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);
}

13.2 为什么从 DSL.trueCondition() 开始 ​

java
Condition condition = DSL.trueCondition();

这相当于初始化一个永远为真的条件,然后后面不断 and(...)。

好处是逻辑统一,不用写很多 if/else 判断第一个条件是谁。


13.3 动态分页查询 ​

把动态条件和分页结合起来:

java
public PageResult<demo.jooq.gen.tables.pojos.SystemAdmin> searchPage(SystemAdminQuery query) {
  Condition condition = DSL.trueCondition();

  if (query.getId() != null) {
    condition = condition.and(SYSTEM_ADMIN.ID.eq(query.getId()));
  }

  if (query.getLoginName() != null && !query.getLoginName().isBlank()) {
    condition = condition.and(SYSTEM_ADMIN.LOGIN_NAME.like("%" + query.getLoginName() + "%"));
  }

  if (query.getPassword() != null && !query.getPassword().isBlank()) {
    condition = condition.and(SYSTEM_ADMIN.PASSWORD.eq(query.getPassword()));
  }

  int pageNo = query.getPageNo() == null || query.getPageNo() < 1 ? 1 : query.getPageNo();
  int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
  int offset = (pageNo - 1) * pageSize;

  Integer total = useDsl()
      .selectCount()
      .from(SYSTEM_ADMIN)
      .where(condition)
      .fetchOne(0, int.class);

  List<demo.jooq.gen.tables.pojos.SystemAdmin> list = useDsl()
      .selectFrom(SYSTEM_ADMIN)
      .where(condition)
      .orderBy(SYSTEM_ADMIN.ID.desc())
      .limit(pageSize)
      .offset(offset)
      .fetchInto(demo.jooq.gen.tables.pojos.SystemAdmin.class);

  return new PageResult<>(pageNo, pageSize, total, list);
}

这就是一个非常完整的后台列表查询写法。


13.4 动态条件拼装的优点 ​

相比 XML 动态 SQL,jOOQ 的 Java 拼装有几个明显优势:

  • 全部字段都是强类型引用
  • 条件逻辑天然跟随 Java 语法
  • IDE 可重构
  • 更容易抽公共方法
  • 更适合复杂组合条件

13.5 可以进一步抽成独立条件方法 ​

为了避免 DAO 方法越来越长,推荐把条件构造抽出来:

java
private Condition buildCondition(SystemAdminQuery query) {
  Condition condition = DSL.trueCondition();

  if (query.getId() != null) {
    condition = condition.and(SYSTEM_ADMIN.ID.eq(query.getId()));
  }

  if (query.getLoginName() != null && !query.getLoginName().isBlank()) {
    condition = condition.and(SYSTEM_ADMIN.LOGIN_NAME.like("%" + query.getLoginName() + "%"));
  }

  if (query.getPassword() != null && !query.getPassword().isBlank()) {
    condition = condition.and(SYSTEM_ADMIN.PASSWORD.eq(query.getPassword()));
  }

  return condition;
}

然后查询里直接:

java
Condition condition = buildCondition(query);

这在项目中会更整洁。


十四、batchInsert 批量插入 ​

批量插入是性能优化中非常常见的一步。

如果一条一条 insert():

java
for (...) {
  record.insert();
}

虽然能用,但数据库往返次数多,性能不理想。

这时就应该考虑 jOOQ 的批量能力。


14.1 基于 Record 的批量插入 ​

java
public int[] batchInsertByPojo(List<demo.jooq.gen.tables.pojos.SystemAdmin> pojos) {
  List<SystemAdminRecord> records = new ArrayList<>();

  for (demo.jooq.gen.tables.pojos.SystemAdmin pojo : pojos) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
    records.add(record);
  }

  return useDsl().batchInsert(records).execute();
}

返回值说明 ​

execute() 返回:

java
int[]

数组中的每个元素通常对应一条语句的执行结果。


14.2 使用场景 ​

适合:

  • 批量导入数据
  • 初始化基础数据
  • 日志类批量落库
  • 一次性插入多条独立记录

14.3 批量插入的注意点 ​

1. 批量不是无限大越好 ​

如果一次传入 10 万条,可能:

  • 占内存
  • SQL 太大
  • JDBC 批处理压力大

通常建议分批,例如:

  • 500 条一批
  • 1000 条一批

2. 批量插入更适合事务包裹 ​

如果希望“要么全部成功,要么全部回滚”,建议在 Service 层事务中调用。

3. 自增主键回填要谨慎 ​

不同数据库 / 驱动 / 批处理方式下,对批量插入后主键回填支持不完全一致。

所以如果非常依赖插入后逐条拿主键,需要做单独验证。


14.4 分批批量插入示例 ​

java
public void batchInsertInChunks(List<demo.jooq.gen.tables.pojos.SystemAdmin> pojos, int chunkSize) {
  if (pojos == null || pojos.isEmpty()) {
    return;
  }

  int size = pojos.size();
  for (int i = 0; i < size; i += chunkSize) {
    int end = Math.min(i + chunkSize, size);
    List<demo.jooq.gen.tables.pojos.SystemAdmin> subList = pojos.subList(i, end);
    batchInsertByPojo(subList);
  }
}

这种方式在导入类场景里很常见。


十五、一个完整 DAO 示例 ​

下面给出一个把本文重点串起来的完整 DAO 示例。

java
package demo.jooq.dao;

import static demo.jooq.gen.tables.SystemAdmin.SYSTEM_ADMIN;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.jooq.Condition;
import org.jooq.DSLContext;
import org.jooq.impl.DSL;

import nexus.io.annotation.Inject;

import demo.jooq.gen.tables.pojos.SystemAdmin;
import demo.jooq.gen.tables.records.SystemAdminRecord;
import demo.jooq.model.PageResult;
import demo.jooq.model.SystemAdminQuery;
import demo.jooq.tx.TransactionContext;

public class SystemAdminDao {

  @Inject
  private DSLContext dsl;

  private DSLContext useDsl() {
    DSLContext txDsl = TransactionContext.get();
    return txDsl != null ? txDsl : dsl;
  }

  public Integer insertByRecord(String loginName, String password) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN);
    record.setLoginName(loginName);
    record.setPassword(password);
    record.insert();
    return record.getId();
  }

  public Integer insertByPojo(SystemAdmin pojo) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
    record.insert();
    return record.getId();
  }

  public Integer saveOrUpdate(SystemAdmin pojo) {
    if (pojo.getId() == null) {
      SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
      record.insert();
      return record.getId();
    } else {
      useDsl()
          .update(SYSTEM_ADMIN)
          .set(SYSTEM_ADMIN.LOGIN_NAME, pojo.getLoginName())
          .set(SYSTEM_ADMIN.PASSWORD, pojo.getPassword())
          .where(SYSTEM_ADMIN.ID.eq(pojo.getId()))
          .execute();
      return pojo.getId();
    }
  }

  public Integer saveWithStore(String loginName, String password) {
    SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN);
    record.setLoginName(loginName);
    record.setPassword(password);
    record.store();
    return record.getId();
  }

  public SystemAdminRecord findRecordById(Integer id) {
    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOne();
  }

  public Optional<SystemAdminRecord> findOptionalRecordById(Integer id) {
    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOptional();
  }

  public SystemAdmin findPojoById(Integer id) {
    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOneInto(SystemAdmin.class);
  }

  public Optional<SystemAdmin> findOptionalPojoById(Integer id) {
    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOptionalInto(SystemAdmin.class);
  }

  public List<SystemAdmin> findAllPojos() {
    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .orderBy(SYSTEM_ADMIN.ID.asc())
        .fetchInto(SystemAdmin.class);
  }

  public boolean updatePasswordByRecord(Integer id, String newPassword) {
    SystemAdminRecord record = useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOne();

    if (record == null) {
      return false;
    }

    record.setPassword(newPassword);
    record.update();
    return true;
  }

  public int updateByPojo(SystemAdmin pojo) {
    return useDsl()
        .update(SYSTEM_ADMIN)
        .set(SYSTEM_ADMIN.LOGIN_NAME, pojo.getLoginName())
        .set(SYSTEM_ADMIN.PASSWORD, pojo.getPassword())
        .where(SYSTEM_ADMIN.ID.eq(pojo.getId()))
        .execute();
  }

  public boolean deleteByRecord(Integer id) {
    SystemAdminRecord record = useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .fetchOne();

    if (record == null) {
      return false;
    }

    record.delete();
    return true;
  }

  public int deleteById(Integer id) {
    return useDsl()
        .deleteFrom(SYSTEM_ADMIN)
        .where(SYSTEM_ADMIN.ID.eq(id))
        .execute();
  }

  public List<SystemAdmin> paginate(int pageNo, int pageSize) {
    int offset = (pageNo - 1) * pageSize;

    return useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .orderBy(SYSTEM_ADMIN.ID.desc())
        .limit(pageSize)
        .offset(offset)
        .fetchInto(SystemAdmin.class);
  }

  public PageResult<SystemAdmin> searchPage(SystemAdminQuery query) {
    Condition condition = buildCondition(query);

    int pageNo = query.getPageNo() == null || query.getPageNo() < 1 ? 1 : query.getPageNo();
    int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
    int offset = (pageNo - 1) * pageSize;

    Integer total = useDsl()
        .selectCount()
        .from(SYSTEM_ADMIN)
        .where(condition)
        .fetchOne(0, int.class);

    List<SystemAdmin> list = useDsl()
        .selectFrom(SYSTEM_ADMIN)
        .where(condition)
        .orderBy(SYSTEM_ADMIN.ID.desc())
        .limit(pageSize)
        .offset(offset)
        .fetchInto(SystemAdmin.class);

    return new PageResult<>(pageNo, pageSize, total, list);
  }

  public int[] batchInsertByPojo(List<SystemAdmin> pojos) {
    List<SystemAdminRecord> records = new ArrayList<>();

    for (SystemAdmin pojo : pojos) {
      SystemAdminRecord record = useDsl().newRecord(SYSTEM_ADMIN, pojo);
      records.add(record);
    }

    return useDsl().batchInsert(records).execute();
  }

  private Condition buildCondition(SystemAdminQuery query) {
    Condition condition = DSL.trueCondition();

    if (query.getId() != null) {
      condition = condition.and(SYSTEM_ADMIN.ID.eq(query.getId()));
    }

    if (query.getLoginName() != null && !query.getLoginName().isBlank()) {
      condition = condition.and(SYSTEM_ADMIN.LOGIN_NAME.like("%" + query.getLoginName() + "%"));
    }

    if (query.getPassword() != null && !query.getPassword().isBlank()) {
      condition = condition.and(SYSTEM_ADMIN.PASSWORD.eq(query.getPassword()));
    }

    return condition;
  }
}

十六、Record、POJO、DSL 三者的最终选型建议 ​

到这里,可以把整个实践总结成一套很清晰的分工原则。


16.1 Table:只负责强类型 SQL ​

例如:

java
SYSTEM_ADMIN
SYSTEM_ADMIN.LOGIN_NAME
SYSTEM_ADMIN.PASSWORD

它是写 SQL 的基础设施。


16.2 Record:适合 DAO 内部的单行持久化操作 ​

适合场景:

  • 单条插入
  • 查询后更新
  • 查询后删除
  • 简洁的 store()

不建议:

  • 直接暴露到 Controller
  • 在跨层中大量传递
  • 强行承担业务 DTO 角色

16.3 POJO:适合查询结果与业务传递 ​

适合场景:

  • DAO 返回结果
  • Service 层输入输出
  • Controller 响应
  • 批量插入输入对象

16.4 DSL 直接语句:适合复杂和高性能场景 ​

适合场景:

  • 批量更新
  • 条件删除
  • 分页查询
  • 动态条件
  • 复杂 SQL
  • 对执行意图要求明确的场景

十七、几个非常容易踩的坑 ​


17.1 不要混淆两个 SystemAdmin ​

你这里会同时存在两个类:

  • demo.jooq.gen.tables.SystemAdmin
  • demo.jooq.gen.tables.pojos.SystemAdmin

推荐写法:

java
import static demo.jooq.gen.tables.SystemAdmin.SYSTEM_ADMIN;
import demo.jooq.gen.tables.pojos.SystemAdmin;

这样:

  • SYSTEM_ADMIN 表示表
  • SystemAdmin 表示 POJO

可读性最好。


17.2 fetchOne() 和 fetchOptional() 都要求结果最多一条 ​

它们不是“随便查第一条”。

如果条件可能返回多条,就不要用它们。

否则应该用:

java
fetch()

或者加上明确的唯一条件。


17.3 store() 很方便,但不要滥用 ​

store() 依赖 Record 状态,不适合所有场景。

在团队开发里,如果想让 SQL 行为更明确:

  • 新增就用 insert()
  • 更新就用 update()

通常比 store() 更易读。


17.4 saveOrUpdate 不是数据库原子 upsert ​

应用层 saveOrUpdate:

  • 先判断
  • 再写

数据库层 upsert:

  • 一条 SQL 原子完成

两者不要混淆。


17.5 批量插入要控制批次大小 ​

不要一次塞太多数据进去。

大批量导入时,应当:

  • 分批处理
  • 配合事务
  • 结合数据库连接池容量和 SQL 大小压测

十八、总结 ​

到这一篇为止,基于 jOOQ Codegen,system_admin 这张表实际上已经具备了三层开发能力:

1. SYSTEM_ADMIN ​

强类型表定义,解决表名字段名的编译期校验问题。

2. SystemAdminRecord ​

面向单行记录的对象式数据库操作,适合 DAO 内部的精细 CRUD。

3. demo.jooq.gen.tables.pojos.SystemAdmin ​

纯数据对象,适合作为查询结果和业务层数据承载体。

而在实际项目中,最推荐的使用方式是:

  • 用 SYSTEM_ADMIN 写强类型 SQL
  • 用 Record 处理 DAO 内部单行持久化
  • 用 POJO 做查询结果和跨层传输
  • 用 DSL 直接语句处理分页、动态条件、批量操作和复杂更新

一句话总结:

jOOQ Codegen 的真正价值,不只是“生成类”,而是把 SQL、记录对象和业务数据对象分层表达出来,让代码既强类型,又清晰可维护。

附录 ​

SystemAdmin ​

java
/*
 * This file is generated by jOOQ.
 */
package demo.jooq.gen.tables;


import demo.jooq.gen.Keys;
import demo.jooq.gen.Public;
import demo.jooq.gen.tables.records.SystemAdminRecord;

import java.util.function.Function;

import org.jooq.Field;
import org.jooq.ForeignKey;
import org.jooq.Function3;
import org.jooq.Identity;
import org.jooq.Name;
import org.jooq.Record;
import org.jooq.Records;
import org.jooq.Row3;
import org.jooq.Schema;
import org.jooq.SelectField;
import org.jooq.Table;
import org.jooq.TableField;
import org.jooq.TableOptions;
import org.jooq.UniqueKey;
import org.jooq.impl.DSL;
import org.jooq.impl.SQLDataType;
import org.jooq.impl.TableImpl;


/**
 * This class is generated by jOOQ.
 */
@SuppressWarnings({ "all", "unchecked", "rawtypes" })
public class SystemAdmin extends TableImpl<SystemAdminRecord> {

    private static final long serialVersionUID = 1L;

    /**
     * The reference instance of <code>public.system_admin</code>
     */
    public static final SystemAdmin SYSTEM_ADMIN = new SystemAdmin();

    /**
     * The class holding records for this type
     */
    @Override
    public Class<SystemAdminRecord> getRecordType() {
        return SystemAdminRecord.class;
    }

    /**
     * The column <code>public.system_admin.id</code>.
     */
    public final TableField<SystemAdminRecord, Integer> ID = createField(DSL.name("id"), SQLDataType.INTEGER.nullable(false).identity(true), this, "");

    /**
     * The column <code>public.system_admin.login_name</code>.
     */
    public final TableField<SystemAdminRecord, String> LOGIN_NAME = createField(DSL.name("login_name"), SQLDataType.VARCHAR(64), this, "");

    /**
     * The column <code>public.system_admin.password</code>.
     */
    public final TableField<SystemAdminRecord, String> PASSWORD = createField(DSL.name("password"), SQLDataType.VARCHAR(64), this, "");

    private SystemAdmin(Name alias, Table<SystemAdminRecord> aliased) {
        this(alias, aliased, null);
    }

    private SystemAdmin(Name alias, Table<SystemAdminRecord> aliased, Field<?>[] parameters) {
        super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table());
    }

    /**
     * Create an aliased <code>public.system_admin</code> table reference
     */
    public SystemAdmin(String alias) {
        this(DSL.name(alias), SYSTEM_ADMIN);
    }

    /**
     * Create an aliased <code>public.system_admin</code> table reference
     */
    public SystemAdmin(Name alias) {
        this(alias, SYSTEM_ADMIN);
    }

    /**
     * Create a <code>public.system_admin</code> table reference
     */
    public SystemAdmin() {
        this(DSL.name("system_admin"), null);
    }

    public <O extends Record> SystemAdmin(Table<O> child, ForeignKey<O, SystemAdminRecord> key) {
        super(child, key, SYSTEM_ADMIN);
    }

    @Override
    public Schema getSchema() {
        return aliased() ? null : Public.PUBLIC;
    }

    @Override
    public Identity<SystemAdminRecord, Integer> getIdentity() {
        return (Identity<SystemAdminRecord, Integer>) super.getIdentity();
    }

    @Override
    public UniqueKey<SystemAdminRecord> getPrimaryKey() {
        return Keys.SYSTEM_ADMIN_PKEY;
    }

    @Override
    public SystemAdmin as(String alias) {
        return new SystemAdmin(DSL.name(alias), this);
    }

    @Override
    public SystemAdmin as(Name alias) {
        return new SystemAdmin(alias, this);
    }

    @Override
    public SystemAdmin as(Table<?> alias) {
        return new SystemAdmin(alias.getQualifiedName(), this);
    }

    /**
     * Rename this table
     */
    @Override
    public SystemAdmin rename(String name) {
        return new SystemAdmin(DSL.name(name), null);
    }

    /**
     * Rename this table
     */
    @Override
    public SystemAdmin rename(Name name) {
        return new SystemAdmin(name, null);
    }

    /**
     * Rename this table
     */
    @Override
    public SystemAdmin rename(Table<?> name) {
        return new SystemAdmin(name.getQualifiedName(), null);
    }

    // -------------------------------------------------------------------------
    // Row3 type methods
    // -------------------------------------------------------------------------

    @Override
    public Row3<Integer, String, String> fieldsRow() {
        return (Row3) super.fieldsRow();
    }

    /**
     * Convenience mapping calling {@link SelectField#convertFrom(Function)}.
     */
    public <U> SelectField<U> mapping(Function3<? super Integer, ? super String, ? super String, ? extends U> from) {
        return convertFrom(Records.mapping(from));
    }

    /**
     * Convenience mapping calling {@link SelectField#convertFrom(Class,
     * Function)}.
     */
    public <U> SelectField<U> mapping(Class<U> toType, Function3<? super Integer, ? super String, ? super String, ? extends U> from) {
        return convertFrom(toType, Records.mapping(from));
    }
}

SystemAdmin ​

java
/*
 * This file is generated by jOOQ.
 */
package demo.jooq.gen.tables.pojos;


import java.io.Serializable;


/**
 * This class is generated by jOOQ.
 */
@SuppressWarnings({ "all", "unchecked", "rawtypes" })
public class SystemAdmin implements Serializable {

    private static final long serialVersionUID = 1L;

    private Integer id;
    private String loginName;
    private String password;

    public SystemAdmin() {}

    public SystemAdmin(SystemAdmin value) {
        this.id = value.id;
        this.loginName = value.loginName;
        this.password = value.password;
    }

    public SystemAdmin(
        Integer id,
        String loginName,
        String password
    ) {
        this.id = id;
        this.loginName = loginName;
        this.password = password;
    }

    /**
     * Getter for <code>public.system_admin.id</code>.
     */
    public Integer getId() {
        return this.id;
    }

    /**
     * Setter for <code>public.system_admin.id</code>.
     */
    public void setId(Integer id) {
        this.id = id;
    }

    /**
     * Getter for <code>public.system_admin.login_name</code>.
     */
    public String getLoginName() {
        return this.loginName;
    }

    /**
     * Setter for <code>public.system_admin.login_name</code>.
     */
    public void setLoginName(String loginName) {
        this.loginName = loginName;
    }

    /**
     * Getter for <code>public.system_admin.password</code>.
     */
    public String getPassword() {
        return this.password;
    }

    /**
     * Setter for <code>public.system_admin.password</code>.
     */
    public void setPassword(String password) {
        this.password = password;
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj)
            return true;
        if (obj == null)
            return false;
        if (getClass() != obj.getClass())
            return false;
        final SystemAdmin other = (SystemAdmin) obj;
        if (this.id == null) {
            if (other.id != null)
                return false;
        }
        else if (!this.id.equals(other.id))
            return false;
        if (this.loginName == null) {
            if (other.loginName != null)
                return false;
        }
        else if (!this.loginName.equals(other.loginName))
            return false;
        if (this.password == null) {
            if (other.password != null)
                return false;
        }
        else if (!this.password.equals(other.password))
            return false;
        return true;
    }

    @Override
    public int hashCode() {
        final int prime = 31;
        int result = 1;
        result = prime * result + ((this.id == null) ? 0 : this.id.hashCode());
        result = prime * result + ((this.loginName == null) ? 0 : this.loginName.hashCode());
        result = prime * result + ((this.password == null) ? 0 : this.password.hashCode());
        return result;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder("SystemAdmin (");

        sb.append(id);
        sb.append(", ").append(loginName);
        sb.append(", ").append(password);

        sb.append(")");
        return sb.toString();
    }
}

SystemAdminRecord ​

java
/*
 * This file is generated by jOOQ.
 */
package demo.jooq.gen.tables.records;


import demo.jooq.gen.tables.SystemAdmin;

import org.jooq.Field;
import org.jooq.Record1;
import org.jooq.Record3;
import org.jooq.Row3;
import org.jooq.impl.UpdatableRecordImpl;


/**
 * This class is generated by jOOQ.
 */
@SuppressWarnings({ "all", "unchecked", "rawtypes" })
public class SystemAdminRecord extends UpdatableRecordImpl<SystemAdminRecord> implements Record3<Integer, String, String> {

    private static final long serialVersionUID = 1L;

    /**
     * Setter for <code>public.system_admin.id</code>.
     */
    public void setId(Integer value) {
        set(0, value);
    }

    /**
     * Getter for <code>public.system_admin.id</code>.
     */
    public Integer getId() {
        return (Integer) get(0);
    }

    /**
     * Setter for <code>public.system_admin.login_name</code>.
     */
    public void setLoginName(String value) {
        set(1, value);
    }

    /**
     * Getter for <code>public.system_admin.login_name</code>.
     */
    public String getLoginName() {
        return (String) get(1);
    }

    /**
     * Setter for <code>public.system_admin.password</code>.
     */
    public void setPassword(String value) {
        set(2, value);
    }

    /**
     * Getter for <code>public.system_admin.password</code>.
     */
    public String getPassword() {
        return (String) get(2);
    }

    // -------------------------------------------------------------------------
    // Primary key information
    // -------------------------------------------------------------------------

    @Override
    public Record1<Integer> key() {
        return (Record1) super.key();
    }

    // -------------------------------------------------------------------------
    // Record3 type implementation
    // -------------------------------------------------------------------------

    @Override
    public Row3<Integer, String, String> fieldsRow() {
        return (Row3) super.fieldsRow();
    }

    @Override
    public Row3<Integer, String, String> valuesRow() {
        return (Row3) super.valuesRow();
    }

    @Override
    public Field<Integer> field1() {
        return SystemAdmin.SYSTEM_ADMIN.ID;
    }

    @Override
    public Field<String> field2() {
        return SystemAdmin.SYSTEM_ADMIN.LOGIN_NAME;
    }

    @Override
    public Field<String> field3() {
        return SystemAdmin.SYSTEM_ADMIN.PASSWORD;
    }

    @Override
    public Integer component1() {
        return getId();
    }

    @Override
    public String component2() {
        return getLoginName();
    }

    @Override
    public String component3() {
        return getPassword();
    }

    @Override
    public Integer value1() {
        return getId();
    }

    @Override
    public String value2() {
        return getLoginName();
    }

    @Override
    public String value3() {
        return getPassword();
    }

    @Override
    public SystemAdminRecord value1(Integer value) {
        setId(value);
        return this;
    }

    @Override
    public SystemAdminRecord value2(String value) {
        setLoginName(value);
        return this;
    }

    @Override
    public SystemAdminRecord value3(String value) {
        setPassword(value);
        return this;
    }

    @Override
    public SystemAdminRecord values(Integer value1, String value2, String value3) {
        value1(value1);
        value2(value2);
        value3(value3);
        return this;
    }

    // -------------------------------------------------------------------------
    // Constructors
    // -------------------------------------------------------------------------

    /**
     * Create a detached SystemAdminRecord
     */
    public SystemAdminRecord() {
        super(SystemAdmin.SYSTEM_ADMIN);
    }

    /**
     * Create a detached, initialised SystemAdminRecord
     */
    public SystemAdminRecord(Integer id, String loginName, String password) {
        super(SystemAdmin.SYSTEM_ADMIN);

        setId(id);
        setLoginName(loginName);
        setPassword(password);
        resetChangedOnNotNull();
    }

    /**
     * Create a detached, initialised SystemAdminRecord
     */
    public SystemAdminRecord(demo.jooq.gen.tables.pojos.SystemAdmin value) {
        super(SystemAdmin.SYSTEM_ADMIN);

        if (value != null) {
            setId(value.getId());
            setLoginName(value.getLoginName());
            setPassword(value.getPassword());
            resetChangedOnNotNull();
        }
    }
}

MIT Licensed