搜索 K
Appearance
Appearance
模型定义好之后,运行期所有读写都从一个静态门面 cn.cvking.forge.data.Models 进入。它把 MyBatis-Plus 的细节包了一层,给你三组入口:查询(origin / repository)、单实体写(of)、批量写(ofBatch),外加关系字段的读填充与写维护。本篇用宠物商店里的 Pet 把增删改查、按编码查询、分页全部跑一遍,并打开 MyBatis 的 SQL 日志,看真实执行的语句长什么样(含逻辑删除条件)。
本篇只讲怎么用;关系字段读写见 关系字段,逻辑删除语义见 逻辑删除。
我们沿用全章统一的 Pet,这里只摘出 CRUD 会用到的标量字段。
@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 会用到。
@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 的标准输出日志即可。
# 打开 SQL 日志,控制台直接打印每条语句与参数
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
cn.cvking.forge: debugTIP
启动期还会打印一行 Lifecycle=..., 模块 N 个, 扫描模型 M 个, 耗时 ...ms 与 [sys_meta] 元数据落库完成,耗时 ... ms(模块 N 个、模型 M 个),确认模型已被扫描注册、元数据已落库后再做下面的 CRUD。启动期机制见 前置知识 · 启动生命周期。
写单条记录从 Models.of(entity) 进入,拿到一个 EntityOps<T>,它有三个方法:
T save() // 主键存在判库决定 insert/update,按唯一键兜底
int insert()
int updateById()最常用的是 save()——它会先看主键,主键为 null 走 insert,否则走 updateById,再按唯一键兜底,所以新增和更新都用它。
// 新增: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_SALE 存 1)。普通 Java 枚举不实现该接口会在启动期校验失败,详见 枚举与字段。
验证结果——开启 SQL 日志后,新增那次控制台打印形如:
==> 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_SALE,create_time / update_time 由 IdModel 的审计填充自动写入。
读侧从 Models.origin(Pet.class) 进入,拿到 OriginQuery<T>,用 where 接收一个 Consumer<LambdaQueryWrapper<T>> 写条件,再调用各种终结方法取结果:
// 单条
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 末尾自动追加了逻辑删除条件(模型启用逻辑删除时):
==> 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: 2TIP
deleted = 0 这段不是你写的,是逻辑删除拦截器 LogicDeleteInnerInterceptor 在 beforeQuery 阶段改写进去的——未删数据才会被查出。语义与配置见 逻辑删除。
删除与按条件更新同样挂在 OriginQuery<T> 上:
// 按主键删除
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,把删除标记列置为删除时间戳:
==> Preparing: UPDATE pet SET deleted = ? WHERE id = ? AND deleted = 0
==> Parameters: 2026-06-04 10:05:00.0(Timestamp), 1(Long)
<== Updates: 1WARNING
deleted 是时间戳语义而非 0/1 布尔——未删时为某约定值、软删后写入删除时刻。要物理删表/删列需要另外的 forge.ddl.allow-drop 开关,与运行期行删除是两回事,见 逻辑删除。
Category 继承 CodeModel<T>,所以可以用编码而非主键查询。OriginQuery<T> 为 CodeModel 子类提供了两个专用方法:
T queryByCode(String code) // 按编码查单条
Map<String, T> queryCodeMap() // 全表按编码建索引用法:
Category cat = Models.origin(Category.class).queryByCode("DOG");
// 一次性把分类按 code 索引成 Map,常用于批量回填
Map<String, Category> codeMap = Models.origin(Category.class).queryCodeMap();
Category dog = codeMap.get("DOG");验证结果——打印形如:
==> Preparing: SELECT id, code, name, create_time, update_time FROM category WHERE deleted = 0 AND (code = ?)
==> Parameters: DOG(String)
<== Total: 1WARNING
务必对真实子类 Category.class 调用 queryByCode,不要试图用 CodeModel::getCode 这类基类 lambda 去写条件。原因见文末「常见反例与排查」。
分页用 queryPage(current, size),返回 MyBatis-Plus 的 IPage<T>:
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 查询,两条都带逻辑删除条件:
==> 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)批量写从 Models.ofBatch(collection) 进入,拿到 BatchOps<T>:
int insertBatch()
int updateBatchById()
int saveBatch() // 按主键和唯一键分流 insert/updateList<Pet> batch = List.of(buildPet("阿黄"), buildPet("小花"), buildPet("球球"));
Models.ofBatch(batch).saveBatch();WARNING
ofBatch 会校验:集合非空、元素非空、元素类型一致。混入 null 元素或不同类型会直接抛异常。
绝大多数场景 origin(...) 已经够用。若你想直接拿 MyBatis-Plus 的 BaseMapper 风格能力,用 Models.repository(Pet.class) 取 Repository<T>,或在 OriginQuery 上调用 wrapper() 拿到底层 LambdaQueryWrapper<T> 自行拼装。一般业务代码优先用 origin,把 Wrapper 细节交给框架。
现象:手写 w.eq(CodeModel::getCode, "DOG") 或类似 lambda 后,MyBatis-Plus 报错,提示某个抽象基类未注册 / 找不到表。
原因:CodeModel 是 ABSTRACT 模型基类,没有自己的表。MyBatis-Plus 解析方法引用时会按 InstantiatedMethodType 把实体误判成声明 getCode 的基类 CodeModel,而它并未作为 STORE 模型注册。
修复:始终对真实子类调用门面方法——用 Models.origin(Category.class).queryByCode("DOG")。框架内部正是直接取真实子类的列元数据来规避这个 lambda 误解析,所以走门面方法即可,不要自己用基类方法引用拼条件。
现象:给 status 用了一个没实现 ValueEnum 的普通枚举,应用启动期就抛校验异常。
原因:落库枚举必须实现 ValueEnum<T>,框架要靠 getValue() 决定入库标量、靠泛型实参推断 EnumStoreType(ValueEnum<Integer> → INT)。普通枚举无法落库。
修复:让枚举 implements ValueEnum<Integer> 并实现 getValue() / getDisplayName(),写法见 枚举与字段。
现象:想给某个表换名或加前缀,但跑出来 SQL 里还是默认表名,改动像没生效。
原因:表名定制必须发生在 MyBatis-Plus 完成表名注入之前。时序错了,定制逻辑跑在注入之后就被覆盖了。
修复:把表名定制挂在框架启动期、表名注入之前的扩展点完成;定时/懒加载式地改表名会错过时机。启动期顺序见 前置知识 · 启动生命周期。
现象:手写 w.eq("deleted", 1) 想查软删数据,结果一条都查不到。
原因:deleted 是时间戳语义而非布尔——未删为约定值、软删后写入删除时刻;同时拦截器已自动在查询里追加 deleted = 0 过滤未删数据,你再叠一个布尔条件就互相矛盾。
修复:常规查询不要手写删除列条件,交给拦截器;确需查归档数据时按时间戳语义处理。语义见 逻辑删除。