Skip to content

CRUD 与 Models 门面

模型定义好之后,运行期所有读写都从一个静态门面 cn.cvking.forge.data.Models 进入。它把 MyBatis-Plus 的细节包了一层,给你三组入口:查询(origin / repository)、单实体写(of)、批量写(ofBatch),外加关系字段的读填充与写维护。本篇用宠物商店里的 Pet 把增删改查、按编码查询、分页全部跑一遍,并打开 MyBatis 的 SQL 日志,看真实执行的语句长什么样(含逻辑删除条件)。

本篇只讲怎么用;关系字段读写见 关系字段,逻辑删除语义见 逻辑删除

准备工作:实体与 SQL 日志

我们沿用全章统一的 Pet,这里只摘出 CRUD 会用到的标量字段。

java
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Pet", displayName = "宠物")
public class Pet extends IdModel<Pet> {

    @Field(displayName = "名称")
    @Field.String(length = 64)
    private String name;

    @Field(displayName = "状态")
    @Field.Enum
    private PetStatus status;

    @Field(displayName = "售价")
    @Field.BigDecimal(precision = 10, scale = 2)
    private BigDecimal price;

    @Field(displayName = "分类ID")
    @Field.Long
    private Long categoryId;
}

Category 继承 CodeModel<T>,因此自带 code 唯一编码列,后面演示 queryByCode 会用到。

java
@Data
@EqualsAndHashCode(callSuper = true)
@Model(name = "pet.Category", displayName = "分类")
public class Category extends CodeModel<Category> {

    @Field(displayName = "名称")
    @Field.String(length = 64)
    private String name;
}

要看真实 SQL,在 application.yml 里打开 MyBatis-Plus 的标准输出日志即可。

yaml
# 打开 SQL 日志,控制台直接打印每条语句与参数
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

logging:
  level:
    cn.cvking.forge: debug

TIP

启动期还会打印一行 Lifecycle=..., 模块 N 个, 扫描模型 M 个, 耗时 ...ms[sys_meta] 元数据落库完成,耗时 ... ms(模块 N 个、模型 M 个),确认模型已被扫描注册、元数据已落库后再做下面的 CRUD。启动期机制见 前置知识 · 启动生命周期

单实体写:of(entity).save()

写单条记录从 Models.of(entity) 进入,拿到一个 EntityOps<T>,它有三个方法:

java
T save()         // 主键存在判库决定 insert/update,按唯一键兜底
int insert()
int updateById()

最常用的是 save()——它会先看主键,主键为 null 走 insert,否则走 updateById,再按唯一键兜底,所以新增和更新都用它。

java
// 新增:id 为 null,走 insert,落库后主键回填到 pet.id
Pet pet = new Pet();
pet.setName("旺财");
pet.setStatus(PetStatus.ON_SALE);
pet.setPrice(new BigDecimal("199.00"));
pet.setCategoryId(1L);
Models.of(pet).save();

// 更新:id 已存在,走 updateById
pet.setStatus(PetStatus.SOLD);
Models.of(pet).save();

WARNING

status 是落库枚举,必须实现 ValueEnum<Integer>,入库存的是 getValue()(这里 ON_SALE1)。普通 Java 枚举不实现该接口会在启动期校验失败,详见 枚举与字段

验证结果——开启 SQL 日志后,新增那次控制台打印形如:

text
==>  Preparing: INSERT INTO pet (name, status, price, category_id, create_time, update_time, id) VALUES (?, ?, ?, ?, ?, ?, ?)
==> Parameters: 旺财(String), 1(Integer), 199.00(BigDecimal), 1(Long), 2026-06-04 10:00:00.0(Timestamp), 2026-06-04 10:00:00.0(Timestamp), 1(Long)
<==    Updates: 1

注意 status 参数是 1 而非 ON_SALEcreate_time / update_timeIdModel 的审计填充自动写入。

条件查询:origin(...).where(...)

读侧从 Models.origin(Pet.class) 进入,拿到 OriginQuery<T>,用 where 接收一个 Consumer<LambdaQueryWrapper<T>> 写条件,再调用各种终结方法取结果:

java
// 单条
Pet one = Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getName, "旺财"))
        .queryOne();

// 列表
List<Pet> onSale = Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getStatus, PetStatus.ON_SALE)
                     .orderByDesc(Pet::getPrice))
        .queryList();

// 计数与存在性
long total = Models.origin(Pet.class).count();
boolean has = Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getCategoryId, 1L))
        .exists();

// 主键查询
Pet byId = Models.origin(Pet.class).queryById(1L);
List<Pet> byIds = Models.origin(Pet.class).queryListByIds(List.of(1L, 2L, 3L));

验证结果——queryList 那次打印形如下面这样,关键是 WHERE 末尾自动追加了逻辑删除条件(模型启用逻辑删除时):

text
==>  Preparing: SELECT id, name, status, price, category_id, create_time, update_time FROM pet WHERE deleted = 0 AND (status = ? ) ORDER BY price DESC
==> Parameters: 1(Integer)
<==      Total: 2

TIP

deleted = 0 这段不是你写的,是逻辑删除拦截器 LogicDeleteInnerInterceptorbeforeQuery 阶段改写进去的——未删数据才会被查出。语义与配置见 逻辑删除

删除与更新

删除与按条件更新同样挂在 OriginQuery<T> 上:

java
// 按主键删除
Models.origin(Pet.class).deleteById(1L);
Models.origin(Pet.class).deleteByIds(List.of(1L, 2L));

// 按条件删除(先 where 再 delete)
Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getStatus, PetStatus.OFF_SHELF))
        .delete();

// 按条件批量更新(把入参实体的非空字段刷到命中行)
Pet patch = new Pet();
patch.setStatus(PetStatus.OFF_SHELF);
Models.origin(Pet.class)
        .where(w -> w.lt(Pet::getPrice, new BigDecimal("10")))
        .update(patch);

验证结果——模型启用逻辑删除时,deleteById 打印的不是 DELETE 而是改写后的 UPDATE,把删除标记列置为删除时间戳:

text
==>  Preparing: UPDATE pet SET deleted = ? WHERE id = ? AND deleted = 0
==> Parameters: 2026-06-04 10:05:00.0(Timestamp), 1(Long)
<==    Updates: 1

WARNING

deleted 是时间戳语义而非 0/1 布尔——未删时为某约定值、软删后写入删除时刻。要物理删表/删列需要另外的 forge.ddl.allow-drop 开关,与运行期行删除是两回事,见 逻辑删除

按编码查询:queryByCode

Category 继承 CodeModel<T>,所以可以用编码而非主键查询。OriginQuery<T>CodeModel 子类提供了两个专用方法:

java
T queryByCode(String code)        // 按编码查单条
Map<String, T> queryCodeMap()     // 全表按编码建索引

用法:

java
Category cat = Models.origin(Category.class).queryByCode("DOG");

// 一次性把分类按 code 索引成 Map,常用于批量回填
Map<String, Category> codeMap = Models.origin(Category.class).queryCodeMap();
Category dog = codeMap.get("DOG");

验证结果——打印形如:

text
==>  Preparing: SELECT id, code, name, create_time, update_time FROM category WHERE deleted = 0 AND (code = ?)
==> Parameters: DOG(String)
<==      Total: 1

WARNING

务必对真实子类 Category.class 调用 queryByCode,不要试图用 CodeModel::getCode 这类基类 lambda 去写条件。原因见文末「常见反例与排查」。

分页查询:queryPage

分页用 queryPage(current, size),返回 MyBatis-Plus 的 IPage<T>

java
IPage<Pet> page = Models.origin(Pet.class)
        .where(w -> w.eq(Pet::getStatus, PetStatus.ON_SALE)
                     .orderByDesc(Pet::getId))
        .queryPage(1, 10);

long totalCount = page.getTotal();   // 总条数
List<Pet> rows  = page.getRecords(); // 当前页数据

验证结果——分页会打印一条 COUNT 与一条 LIMIT 查询,两条都带逻辑删除条件:

text
==>  Preparing: SELECT COUNT(*) FROM pet WHERE deleted = 0 AND (status = ?)
==> Parameters: 1(Integer)
==>  Preparing: SELECT id, name, status, price, category_id, create_time, update_time FROM pet WHERE deleted = 0 AND (status = ?) ORDER BY id DESC LIMIT ?
==> Parameters: 1(Integer), 10(Long)

批量写:ofBatch

批量写从 Models.ofBatch(collection) 进入,拿到 BatchOps<T>

java
int insertBatch()
int updateBatchById()
int saveBatch()      // 按主键和唯一键分流 insert/update
java
List<Pet> batch = List.of(buildPet("阿黄"), buildPet("小花"), buildPet("球球"));
Models.ofBatch(batch).saveBatch();

WARNING

ofBatch 会校验:集合非空、元素非空、元素类型一致。混入 null 元素或不同类型会直接抛异常。

Repository:需要原生 Wrapper 时

绝大多数场景 origin(...) 已经够用。若你想直接拿 MyBatis-Plus 的 BaseMapper 风格能力,用 Models.repository(Pet.class)Repository<T>,或在 OriginQuery 上调用 wrapper() 拿到底层 LambdaQueryWrapper<T> 自行拼装。一般业务代码优先用 origin,把 Wrapper 细节交给框架。

常见反例与排查

反例 1:用 CodeModel::getCode lambda 写编码条件,报实体未注册

现象:手写 w.eq(CodeModel::getCode, "DOG") 或类似 lambda 后,MyBatis-Plus 报错,提示某个抽象基类未注册 / 找不到表。

原因:CodeModelABSTRACT 模型基类,没有自己的表。MyBatis-Plus 解析方法引用时会按 InstantiatedMethodType 把实体误判成声明 getCode 的基类 CodeModel,而它并未作为 STORE 模型注册。

修复:始终对真实子类调用门面方法——用 Models.origin(Category.class).queryByCode("DOG")。框架内部正是直接取真实子类的列元数据来规避这个 lambda 误解析,所以走门面方法即可,不要自己用基类方法引用拼条件。

反例 2:枚举字段直接用普通 Java 枚举,启动即失败

现象:给 status 用了一个没实现 ValueEnum 的普通枚举,应用启动期就抛校验异常。

原因:落库枚举必须实现 ValueEnum<T>,框架要靠 getValue() 决定入库标量、靠泛型实参推断 EnumStoreTypeValueEnum<Integer> → INT)。普通枚举无法落库。

修复:让枚举 implements ValueEnum<Integer> 并实现 getValue() / getDisplayName(),写法见 枚举与字段

反例 3:在 MyBatis-Plus 表名注入完成前定制表名,改动不生效

现象:想给某个表换名或加前缀,但跑出来 SQL 里还是默认表名,改动像没生效。

原因:表名定制必须发生在 MyBatis-Plus 完成表名注入之前。时序错了,定制逻辑跑在注入之后就被覆盖了。

修复:把表名定制挂在框架启动期、表名注入之前的扩展点完成;定时/懒加载式地改表名会错过时机。启动期顺序见 前置知识 · 启动生命周期

反例 4:以为 deleted 是 0/1 布尔,按布尔比较查不到数据

现象:手写 w.eq("deleted", 1) 想查软删数据,结果一条都查不到。

原因:deleted 是时间戳语义而非布尔——未删为约定值、软删后写入删除时刻;同时拦截器已自动在查询里追加 deleted = 0 过滤未删数据,你再叠一个布尔条件就互相矛盾。

修复:常规查询不要手写删除列条件,交给拦截器;确需查归档数据时按时间戳语义处理。语义见 逻辑删除