Compare commits
4 Commits
8ef18a8e85
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bf9a3295a | |||
| 0a7e38f931 | |||
| fc9cb14daf | |||
| 4abb65129b |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -54,12 +54,7 @@ buildNumber.properties
|
|||||||
*.rar
|
*.rar
|
||||||
hs_err_pid*
|
hs_err_pid*
|
||||||
replay_pid*
|
replay_pid*
|
||||||
.vscode/*
|
.vscode/
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/*.code-snippets
|
|
||||||
.history/
|
.history/
|
||||||
*.vsix
|
*.vsix
|
||||||
.metadata
|
.metadata
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"java.compile.nullAnalysis.mode": "automatic",
|
|
||||||
"java.configuration.updateBuildConfiguration": "automatic"
|
|
||||||
}
|
|
||||||
295
README.md
295
README.md
@@ -1,260 +1,109 @@
|
|||||||
# Spring Boot Service Template
|
# Spring Boot Service Template
|
||||||
|
|
||||||
微服务快速开发能力模板,提供标准化的能力模块,帮助开发者快速构建 Spring Boot 微服务应用。
|
微服务快速开发能力模板,v1.1.0-SNAPSHOT。Java 17, Spring Boot 4.0.0, Spring Cloud 2025.1.0。
|
||||||
|
|
||||||
**当前版本**: 1.1.0-SNAPSHOT
|
GroupId: `com.lanyuanxiaoyao`,根包: `com.lanyuanxiaoyao.service.template`。
|
||||||
|
|
||||||
## 1. 项目概述
|
## 模块
|
||||||
|
|
||||||
Spring Boot Service Template 是一个能力模块化的微服务开发模板。每个能力模块封装特定领域的通用功能,开发者只需引入依赖、实现少量代码即可获得完整的业务能力。
|
|
||||||
|
|
||||||
**核心价值**:
|
|
||||||
- 能力模块化:每个模块独立可用,按需引入
|
|
||||||
- 零样板代码:通用逻辑已封装,专注业务实现
|
|
||||||
- 多实现支持:同一能力支持多种技术实现,灵活选择
|
|
||||||
|
|
||||||
## 2. 架构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
spring-boot-service-template/ (根 POM, packaging=pom)
|
||||||
│ Spring Boot Service Template │
|
├── spring-boot-service-template-common/ (jar — 通用工具)
|
||||||
├─────────────────────────────────────────────────────────────────────┤
|
└── spring-boot-service-template-database/ (jar — JPA 数据库能力)
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 能力模块 (Capability) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
|
|
||||||
│ │ │ database │ │ user │ │ payment │ ... │ │
|
|
||||||
│ │ │ ✅ 已实现 │ │ 🔜 规划中 │ │ 🔜 规划中 │ │ │
|
|
||||||
│ │ └──────┬──────┘ └─────────────┘ └─────────────┘ │ │
|
|
||||||
│ │ │ │ │
|
|
||||||
│ │ ▼ │ │
|
|
||||||
│ │ ┌─────────────────────────────────────────────┐ │ │
|
|
||||||
│ │ │ jpa │ eq │ xbatis (实现) │ │ │
|
|
||||||
│ │ └─────────────────────────────────────────────┘ │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 通用基础 (Common) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ ObjectHelper (对象工具) │ SnowflakeHelper (ID生成) │ │
|
|
||||||
│ └──────────────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**模块依赖关系**:
|
模块命名规则: `spring-boot-service-template-{capability}[-{impl}]`
|
||||||
|
|
||||||
|
## 包结构
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐
|
com.lanyuanxiaoyao.service.template.{module}/
|
||||||
│ 应用 │
|
configuration/ # @Configuration(用 configuration 不用 config)
|
||||||
└──────┬──────┘
|
controller/ # REST 控制器 + 接口
|
||||||
│
|
entity/ # JPA 实体、Query、GlobalResponse 等数据对象
|
||||||
▼
|
exception/ # 自定义异常(用 exception 不用 exceptions)
|
||||||
┌─────────────────┐ ┌───────────────────┐
|
helper/ # 工具类(用 helper 不用 util/utils)
|
||||||
│ capability-common│────▶│ capability-impl │
|
repository/ # Spring Data 仓库
|
||||||
│ (接口定义) │ │ (jpa/eq/xbatis) │
|
service/ # 业务服务
|
||||||
└───────┬─────────┘ └───────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────┐
|
|
||||||
│ common │
|
|
||||||
│ (通用工具) │
|
|
||||||
└─────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. 通用规范
|
无独立 DTO/VO 包,DTO 作为 Controller 内部 record。
|
||||||
|
|
||||||
### 3.1 模块命名
|
## database 模块
|
||||||
|
|
||||||
| 模块类型 | 命名格式 | 示例 |
|
单表 CRUD → REST 接口快速实现框架。基于 JPA + Fenix + QueryDSL + MapStruct。
|
||||||
|---------|---------|------|
|
|
||||||
| 能力模块 | `spring-boot-service-template-{capability}` | database |
|
|
||||||
| 通用定义 | `spring-boot-service-template-{capability}-common` | database-common |
|
|
||||||
| 具体实现 | `spring-boot-service-template-{capability}-{impl}` | database-jpa |
|
|
||||||
| 通用基础 | `spring-boot-service-template-common` | common |
|
|
||||||
|
|
||||||
### 3.2 依赖原则
|
**文档**:
|
||||||
|
- [开发指南](docs/database-development.md) - 模块架构、核心设计、技术实现、扩展指南
|
||||||
|
- [使用指南](docs/database-usage.md) - 快速开始、API 接口、查询条件、高级用法
|
||||||
|
|
||||||
- **common 模块**:与业务无关的工具类,不依赖任何能力模块
|
## 开发规范
|
||||||
- **capability-common**:定义接口和通用实体,不依赖具体实现技术
|
|
||||||
- **最小化第三方依赖**:优先使用 JDK 和 Spring 内置能力
|
|
||||||
|
|
||||||
### 3.3 响应格式
|
### 命名
|
||||||
|
|
||||||
所有 API 使用统一的 `GlobalResponse<T>` 格式:
|
| 类别 | 模式 | 示例 |
|
||||||
|
| --------------- | ---------------------------------------- | ---------------------------------- |
|
||||||
|
| 实体基类 | `*Entity` | `IdOnlyEntity`, `SimpleEntity` |
|
||||||
|
| 业务实体 | 纯名词 | `Employee` |
|
||||||
|
| 控制器接口 | `*Controller` | `SaveController` |
|
||||||
|
| 控制器抽象基类 | `*ControllerSupport` | `SimpleControllerSupport` |
|
||||||
|
| 服务接口 | `*Service` | `SaveService` |
|
||||||
|
| 服务抽象基类 | `*ServiceSupport` | `SimpleServiceSupport` |
|
||||||
|
| 仓库 | `*Repository` | `EmployeeRepository` |
|
||||||
|
| 工具类 | `*Helper` | `ObjectHelper` |
|
||||||
|
| 配置类 | `*Configuration` | `QueryDSLConfiguration` |
|
||||||
|
| 异常 | `*Exception` | `IdNotFoundException` |
|
||||||
|
| 内部 DTO record | `SaveItem`/`ListItem`/`DetailItem` | Controller 内部 |
|
||||||
|
|
||||||
```json
|
- 接口不加 `I` 前缀
|
||||||
{
|
- 方法: 动词开头 `save()`/`list()`/`detail()`/`remove()`;布尔用 `is`/`has` 前缀
|
||||||
"status": 0,
|
- 常量 UPPER_SNAKE_CASE;字段常量用 `@FieldNameConstants` 生成 `{Class}.Fields.{field}`
|
||||||
"message": "OK",
|
- 泛型: 简单用 `T`/`E`,领域用 `ENTITY`/`SAVE_ITEM`/`LIST_ITEM`/`DETAIL_ITEM`
|
||||||
"data": { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 字段 | 说明 |
|
### 代码风格
|
||||||
|-----|------|
|
|
||||||
| status | 状态码:0=成功,500=错误 |
|
|
||||||
| message | 描述信息 |
|
|
||||||
| data | 业务数据 |
|
|
||||||
|
|
||||||
列表数据封装:
|
- 2 空格缩进,K&R 大括号,不用 Tab
|
||||||
|
- Import 排序: `java.*` → `jakarta.*` → 第三方 → `com.lanyuanxiaoyao.*`,不用通配符
|
||||||
|
- 积极使用 Java 17 特性: `record`(DTO) / `var` / `instanceof` 模式匹配 / Text Blocks / `String.formatted()`
|
||||||
|
- JavaDoc 用中文,含 `@param`/`@return`/`@see`,可用 `<p>`/`<h3>`/`<ul>`/`<pre>`
|
||||||
|
- 不写 `//` 行内注释,除非必要
|
||||||
|
|
||||||
```json
|
### 注解
|
||||||
{
|
|
||||||
"status": 0,
|
|
||||||
"message": "OK",
|
|
||||||
"data": {
|
|
||||||
"items": [...],
|
|
||||||
"total": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 代码风格
|
用:
|
||||||
|
|
||||||
- **接口与实现分离**:接口定义在 `*-common`,实现在具体模块
|
- 实体: `@Getter` `@Setter` `@ToString` `@FieldNameConstants`,子类加 `@ToString(callSuper = true)`
|
||||||
- **空值检查**:使用 `ObjectHelper.isNull/isNotNull/isEmpty/isNotEmpty`
|
- 日志: `@Slf4j`
|
||||||
- **异常定义**:继承 `RuntimeException`,构造器接收业务参数
|
- 注入: `@RequiredArgsConstructor(access = AccessLevel.PROTECTED)`(构造器注入)
|
||||||
- **注解使用**:`@Getter` / `@Setter` / `@FieldNameConstants` / `@NoArgsConstructor`
|
- 事务: 写 `@Transactional(rollbackFor = Throwable.class)`,读 `@Transactional(readOnly = true)`
|
||||||
|
- 实体基类: `@MappedSuperclass` `@EntityListeners(AuditingEntityListener.class)`
|
||||||
|
|
||||||
## 4. 能力模块
|
不用:
|
||||||
|
|
||||||
### 4.1 database 模块
|
- 不用 `@Valid`/`@NotNull` 等校验注解
|
||||||
|
- 不用 Swagger/OpenAPI 注解
|
||||||
|
- 不用 `@Autowired` 字段注入
|
||||||
|
- 不写代码注释(`//`),除非必要
|
||||||
|
|
||||||
单表 CRUD → REST 接口的快速实现,支持三种数据库访问方式。
|
### 异常
|
||||||
|
|
||||||
#### 模块概述
|
继承 `RuntimeException`,构造器用 `String.formatted()`。查无数据用 `Optional.orElseThrow(() -> new IdNotFoundException(id))`。无全局 `@ControllerAdvice`。
|
||||||
|
|
||||||
| 功能 | 说明 |
|
### 依赖管理
|
||||||
|-----|------|
|
|
||||||
| 保存 | 单条/批量保存,自动判断新增/更新 |
|
|
||||||
| 查询 | 详情查询、列表查询、分页查询、条件查询 |
|
|
||||||
| 删除 | 单条/批量删除 |
|
|
||||||
|
|
||||||
#### 实现方式对比
|
版本集中在根 POM `<properties>` + `<dependencyManagement>`,子模块不声明版本。
|
||||||
|
全局继承: `lombok` / `jspecify:1.0.0`
|
||||||
|
核心: `spring-boot-starter-data-jpa` / `fenix-spring-boot-starter:4.0.0` / `querydsl-jpa:7.1` / `mapstruct:1.6.3`
|
||||||
|
database 模块注解处理器: lombok → hibernate-jpamodelgen → querydsl-apt(jpa) → jakarta.persistence-api → mapstruct-processor
|
||||||
|
|
||||||
| 特性 | JPA | Easy Query | Xbatis |
|
### 通用原则
|
||||||
|-----|-----|------------|--------|
|
|
||||||
| 类型安全查询 | ✅ QueryDSL | ✅ Proxy API | ❌ |
|
|
||||||
| 自动审计 | ✅ | ❌ | ❌ |
|
|
||||||
| 逻辑删除 | ❌ | ✅ | ✅ |
|
|
||||||
| 复杂 SQL | 中等 | 中等 | 强 |
|
|
||||||
| 适用场景 | 标准 JPA 项目 | 类型安全 + 逻辑删除 | 复杂查询场景 |
|
|
||||||
|
|
||||||
#### 快速开始
|
- 文档/注释/commit 用中文,代码标识符用英文
|
||||||
|
- 不引入新依赖前先复用已有组件,优先 JDK 和 Spring 内置能力
|
||||||
|
- 构造器注入,不使用 `@Autowired` 字段注入
|
||||||
|
|
||||||
**1. 添加依赖**
|
### 构建
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- JPA 实现 -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.lanyuanxiaoyao</groupId>
|
|
||||||
<artifactId>spring-boot-service-template-database-jpa</artifactId>
|
|
||||||
<version>1.1.0-SNAPSHOT</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. 实现 CRUD 的 5 个步骤**
|
|
||||||
|
|
||||||
参考测试代码:`spring-boot-service-template-database-jpa/src/test/java/.../`
|
|
||||||
|
|
||||||
| 步骤 | 说明 | 参考文件 |
|
|
||||||
|-----|------|---------|
|
|
||||||
| 1. 创建实体 | 继承 `SimpleEntity` | `entity/Employee.java` |
|
|
||||||
| 2. 创建 Repository | 继承 `SimpleRepository<ENTITY>` | `repository/EmployeeRepository.java` |
|
|
||||||
| 3. 创建 Service | 继承 `SimpleServiceSupport<ENTITY>` | `service/EmployeeService.java` |
|
|
||||||
| 4. 创建 DTO | 定义 SaveItem, ListItem, DetailItem | `controller/EmployeeController.java` |
|
|
||||||
| 5. 创建 Controller | 继承 `SimpleControllerSupport`,实现 3 个 Mapper | `controller/EmployeeController.java` |
|
|
||||||
|
|
||||||
**3. 查询条件语法**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"query": {
|
|
||||||
"equal": { "name": "张三" },
|
|
||||||
"like": { "name": "%张%" },
|
|
||||||
"contain": { "name": "张" },
|
|
||||||
"startWith": { "name": "张" },
|
|
||||||
"endWith": { "name": "三" },
|
|
||||||
"great": { "age": 18 },
|
|
||||||
"less": { "age": 60 },
|
|
||||||
"greatEqual": { "age": 18 },
|
|
||||||
"lessEqual": { "age": 60 },
|
|
||||||
"between": { "age": { "start": 18, "end": 60 } },
|
|
||||||
"inside": { "id": [1, 2, 3] },
|
|
||||||
"notInside": { "id": [1, 2, 3] },
|
|
||||||
"nullEqual": ["deletedTime"],
|
|
||||||
"notNullEqual": ["name"]
|
|
||||||
},
|
|
||||||
"sort": [{ "column": "createdTime", "direction": "DESC" }],
|
|
||||||
"page": { "index": 1, "size": 10 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 模块特定规范
|
|
||||||
|
|
||||||
**分层结构**:
|
|
||||||
```
|
|
||||||
Controller (HTTP处理、DTO转换)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Service (业务逻辑、事务管理)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Repository/Mapper (数据访问)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
Entity (数据模型)
|
|
||||||
```
|
|
||||||
|
|
||||||
**泛型参数**:
|
|
||||||
- `ENTITY` - 实体类型
|
|
||||||
- `SAVE_ITEM` - 保存请求 DTO
|
|
||||||
- `LIST_ITEM` - 列表响应 DTO
|
|
||||||
- `DETAIL_ITEM` - 详情响应 DTO
|
|
||||||
|
|
||||||
**实体继承**:
|
|
||||||
- JPA: `IdOnlyEntity` → `SimpleEntity` → 业务实体
|
|
||||||
- EQ/Xbatis: `IdOnlyEntity` → `SimpleEntity` 或 `LogicDeleteEntity`
|
|
||||||
|
|
||||||
### 4.2 user 模块
|
|
||||||
|
|
||||||
🚧 规划中...
|
|
||||||
|
|
||||||
### 4.3 payment 模块
|
|
||||||
|
|
||||||
🚧 规划中...
|
|
||||||
|
|
||||||
## 5. 技术栈
|
|
||||||
|
|
||||||
| 组件 | 版本 |
|
|
||||||
|-----|------|
|
|
||||||
| Java | 17 |
|
|
||||||
| Spring Boot | 4.0.0 |
|
|
||||||
| Spring Cloud | 2025.1.0 |
|
|
||||||
| Hibernate | 7.1.8.Final |
|
|
||||||
| QueryDSL | 7.1 |
|
|
||||||
| Easy Query | 3.1.68 |
|
|
||||||
| Xbatis | 1.9.7-spring-boot4 |
|
|
||||||
| Lombok | - |
|
|
||||||
| MapStruct | 1.6.3 |
|
|
||||||
|
|
||||||
## 6. 开发与测试
|
|
||||||
|
|
||||||
**构建项目**:
|
|
||||||
```bash
|
```bash
|
||||||
mvn clean package
|
mvn clean package
|
||||||
```
|
```
|
||||||
|
|
||||||
**运行测试**:
|
|
||||||
```bash
|
|
||||||
mvn test
|
|
||||||
```
|
|
||||||
|
|
||||||
测试使用 H2 内存数据库,无需额外配置。
|
|
||||||
|
|
||||||
## 7. 文档索引
|
|
||||||
|
|
||||||
- 详细文档:`docs/` 目录
|
|
||||||
- 测试示例:各模块的 `src/test/java/` 目录
|
|
||||||
|
|||||||
205
docs/database-development.md
Normal file
205
docs/database-development.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Database 模块开发指南
|
||||||
|
|
||||||
|
单表 CRUD → REST 接口快速实现框架。基于 JPA + Fenix + QueryDSL + MapStruct。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
Controller (REST) → Service (业务) → Repository (数据访问) → Entity (模型)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 组件结构
|
||||||
|
|
||||||
|
| 包 | 组件 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| entity | IdOnlyEntity, SimpleEntity | 实体基类 |
|
||||||
|
| entity | SnowflakeId, SnowflakeIdGenerator | ID 生成 |
|
||||||
|
| entity | Query, GlobalResponse, Page | 查询/响应封装 |
|
||||||
|
| repository | SimpleRepository | 统一数据访问接口 |
|
||||||
|
| service | SaveService, QueryService, RemoveService | 功能接口 |
|
||||||
|
| service | SimpleService, SimpleServiceSupport | 组合接口与实现 |
|
||||||
|
| service | QueryParser | 查询条件解析 |
|
||||||
|
| controller | SaveController, QueryController, RemoveController | REST 接口 |
|
||||||
|
| controller | SimpleController, SimpleControllerSupport | 组合接口与实现 |
|
||||||
|
| helper | DatabaseHelper, SnowflakeHelper | 工具类 |
|
||||||
|
| exception | *Exception | 异常定义 |
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 实体继承
|
||||||
|
|
||||||
|
```
|
||||||
|
IdOnlyEntity (id: Long, @SnowflakeId)
|
||||||
|
↑
|
||||||
|
SimpleEntity (+ createdTime, modifiedTime)
|
||||||
|
↑
|
||||||
|
业务实体 (@Entity)
|
||||||
|
```
|
||||||
|
|
||||||
|
**实现要点**:
|
||||||
|
- `@MappedSuperclass` 标记基类
|
||||||
|
- `@SnowflakeId` 触发 `SnowflakeIdGenerator` 生成 ID
|
||||||
|
- `@CreatedDate/@LastModifiedDate` + `AuditingEntityListener` 自动填充时间
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
|
||||||
|
```java
|
||||||
|
@NoRepositoryBean
|
||||||
|
public interface SimpleRepository<E> extends
|
||||||
|
FenixJpaRepository<E, Long>, // CRUD + Fenix
|
||||||
|
FenixJpaSpecificationExecutor<E>, // Specification
|
||||||
|
ListQueryByExampleExecutor<E>, // Example
|
||||||
|
ListQuerydslPredicateExecutor<E> {} // QueryDSL
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心能力**:
|
||||||
|
- `saveOrUpdateByNotNullProperties()` - 部分字段更新
|
||||||
|
- Specification - 动态条件查询
|
||||||
|
- QueryDSL - 类型安全查询
|
||||||
|
|
||||||
|
### Service 接口组合
|
||||||
|
|
||||||
|
```java
|
||||||
|
SaveService<ENTITY> // save(entity), save(entities)
|
||||||
|
QueryService<ENTITY> // detail(id), list(), list(query), count()
|
||||||
|
RemoveService<ENTITY> // remove(id), remove(ids)
|
||||||
|
|
||||||
|
SimpleService<ENTITY> extends SaveService, QueryService, RemoveService
|
||||||
|
```
|
||||||
|
|
||||||
|
**SimpleServiceSupport 实现**:
|
||||||
|
- 保存:Fenix `saveOrUpdateByNotNullProperties()`
|
||||||
|
- 查询:JPA Criteria + Specification
|
||||||
|
- 删除:`deleteBatchByIds()`
|
||||||
|
- 扩展点:重写 `commonPredicates()` 添加全局过滤条件
|
||||||
|
|
||||||
|
### Controller 接口组合
|
||||||
|
|
||||||
|
```java
|
||||||
|
SaveController<SAVE_ITEM> // POST /save
|
||||||
|
QueryController<LIST_ITEM, DETAIL_ITEM> // GET/POST /list, GET /detail/{id}
|
||||||
|
RemoveController // GET /remove/{id}
|
||||||
|
|
||||||
|
SimpleController<SAVE_ITEM, LIST_ITEM, DETAIL_ITEM>
|
||||||
|
```
|
||||||
|
|
||||||
|
**SimpleControllerSupport 实现**:
|
||||||
|
- 调用 Service 方法
|
||||||
|
- 通过 Mapper 转换 DTO ↔ Entity
|
||||||
|
- 封装 GlobalResponse
|
||||||
|
- 扩展点:实现 `saveItemMapper()`, `listItemMapper()`, `detailItemMapper()`
|
||||||
|
|
||||||
|
### 查询条件
|
||||||
|
|
||||||
|
**Query 结构**:`Query(query: Queryable, sort: List<Sortable>, page: Pageable)`
|
||||||
|
|
||||||
|
**QueryParser**:抽象类定义解析接口,`JpaQueryParser` 转换为 JPA Predicate
|
||||||
|
|
||||||
|
**支持操作**:
|
||||||
|
|
||||||
|
| 类别 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| 空值 | nullEqual, notNullEqual, empty, notEmpty |
|
||||||
|
| 相等 | equal, notEqual |
|
||||||
|
| 模糊 | like, notLike, contain, notContain |
|
||||||
|
| 前后缀 | startWith, endWith, notStartWith, notEndWith |
|
||||||
|
| 比较 | great, less, greatEqual, lessEqual |
|
||||||
|
| 区间 | between, notBetween |
|
||||||
|
| 集合 | inside, notInside |
|
||||||
|
|
||||||
|
**实现**:JPA CriteriaBuilder 构建,支持多级字段路径(如 `user.name`),自动类型转换(枚举、LocalDateTime)
|
||||||
|
|
||||||
|
### 响应格式
|
||||||
|
|
||||||
|
```java
|
||||||
|
GlobalResponse<T>(status, message, data)
|
||||||
|
// status: 0 成功, 500 失败
|
||||||
|
// 列表: data = ListItem(items, total)
|
||||||
|
// 详情: data = DetailItem(item)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
|
||||||
|
### 雪花算法
|
||||||
|
|
||||||
|
`SnowflakeHelper`:64 位 Long,1 位符号 + 41 位时间戳 + 10 位机器 ID + 12 位序列号
|
||||||
|
|
||||||
|
`SnowflakeIdGenerator`:实现 `IdentifierGenerator`,持久化时调用 `SnowflakeHelper.next()`
|
||||||
|
|
||||||
|
### 部分更新
|
||||||
|
|
||||||
|
Fenix `saveOrUpdateByNotNullProperties()`:
|
||||||
|
- 自动判断 INSERT/UPDATE
|
||||||
|
- 仅更新非 null 字段
|
||||||
|
|
||||||
|
### 命名策略
|
||||||
|
|
||||||
|
`PhysicalNamingStrategySnakeCaseImpl`:camelCase → snake_case
|
||||||
|
|
||||||
|
### 注解处理器
|
||||||
|
|
||||||
|
执行顺序:lombok → hibernate-jpamodelgen → querydsl-apt → mapstruct-processor
|
||||||
|
|
||||||
|
生成:getter/setter、JPA 元模型(_Entity)、QueryDSL Q 类(QEntity)、MapStruct Mapper
|
||||||
|
|
||||||
|
## 工具类
|
||||||
|
|
||||||
|
### DatabaseHelper
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 生成 DDL
|
||||||
|
generateDDL(entityPackages, ddlFilePath, dialect, jdbc, username, password, driver)
|
||||||
|
|
||||||
|
// 生成脚手架
|
||||||
|
generateBasicFiles(entityPackages, projectRootPackage, projectRootPath, override)
|
||||||
|
```
|
||||||
|
|
||||||
|
### SnowflakeHelper
|
||||||
|
|
||||||
|
```java
|
||||||
|
Long id = SnowflakeHelper.next();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展指南
|
||||||
|
|
||||||
|
### 软删除
|
||||||
|
|
||||||
|
1. 创建 `SoftDeleteEntity extends SimpleEntity`,添加 `deleted` 字段
|
||||||
|
2. 重写 `commonPredicates()` 返回 `deleted = false` 条件
|
||||||
|
3. 覆盖 `remove()` 改为更新 `deleted` 字段
|
||||||
|
|
||||||
|
### 多租户
|
||||||
|
|
||||||
|
1. 创建 `TenantEntity extends SimpleEntity`,添加 `tenantId` 字段
|
||||||
|
2. 重写 `commonPredicates()` 添加租户过滤
|
||||||
|
3. ThreadLocal 或 Spring Security 存储当前租户
|
||||||
|
|
||||||
|
### 审计字段
|
||||||
|
|
||||||
|
在 `SimpleEntity` 添加 `createdBy`, `modifiedBy`,配合 Spring Security 获取当前用户
|
||||||
|
|
||||||
|
### 自定义查询操作符
|
||||||
|
|
||||||
|
1. `Query.Queryable` 添加字段
|
||||||
|
2. `QueryParser` 添加抽象方法
|
||||||
|
3. `JpaQueryParser` 实现转换为 Predicate
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- H2 内存数据库
|
||||||
|
- `@DataJpaTest` 测试 Repository
|
||||||
|
- `@WebMvcTest` 测试 Controller
|
||||||
|
- 测试用例:`src/test/java/.../integration/`
|
||||||
|
|
||||||
|
## 依赖
|
||||||
|
|
||||||
|
核心:spring-boot-starter-data-jpa, fenix-spring-boot-starter:4.0.0, querydsl-jpa:7.1, mapstruct:1.6.3
|
||||||
|
|
||||||
|
传递:spring-boot-service-template-common
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 事务:写操作 `@Transactional(rollbackFor = Throwable.class)`,读操作 `@Transactional(readOnly = true)`
|
||||||
|
- 异常:不使用全局处理器,直接抛出
|
||||||
|
- 性能:批量操作使用批量方法
|
||||||
|
- 线程安全:GlobalResponse 用 record 保证不可变,SnowflakeHelper 用原子变量
|
||||||
409
docs/database-usage.md
Normal file
409
docs/database-usage.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Database 模块使用指南
|
||||||
|
|
||||||
|
单表 CRUD → REST 接口快速实现框架。
|
||||||
|
|
||||||
|
## 使用模式
|
||||||
|
|
||||||
|
### Web 应用
|
||||||
|
|
||||||
|
引入 database 模块,创建 Entity → Repository → Service → Controller,实现 REST 接口。
|
||||||
|
|
||||||
|
### 非 Web 应用
|
||||||
|
|
||||||
|
**无需引入 web 依赖**(database 模块的 `spring-boot-starter-web` scope 为 `provided`)。
|
||||||
|
|
||||||
|
仅使用 Entity → Repository → Service,直接注入 Service 使用:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@SpringBootApplication
|
||||||
|
public class Application implements CommandLineRunner {
|
||||||
|
@Autowired
|
||||||
|
private EmployeeService service;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(String... args) {
|
||||||
|
Employee emp = new Employee();
|
||||||
|
emp.setName("张三");
|
||||||
|
Long id = service.save(emp);
|
||||||
|
Employee found = service.detail(id);
|
||||||
|
List<Employee> list = service.list();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
适用:批处理、定时任务、数据迁移、命令行应用、后台服务。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 添加依赖
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.lanyuanxiaoyao</groupId>
|
||||||
|
<artifactId>spring-boot-service-template-database</artifactId>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建实体
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Getter @Setter
|
||||||
|
@ToString(callSuper = true)
|
||||||
|
@FieldNameConstants
|
||||||
|
@Entity
|
||||||
|
@Table(name = "employee")
|
||||||
|
public class Employee extends SimpleEntity {
|
||||||
|
@Column(comment = "员工姓名", nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(comment = "部门ID")
|
||||||
|
private Long departmentId;
|
||||||
|
|
||||||
|
@Column(comment = "邮箱")
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
继承 `SimpleEntity` 自动获得 `id`(雪花算法)、`createdTime`、`modifiedTime`(自动填充)。
|
||||||
|
|
||||||
|
### 3. 创建 Repository
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Repository
|
||||||
|
public interface EmployeeRepository extends SimpleRepository<Employee> {}
|
||||||
|
```
|
||||||
|
|
||||||
|
继承能力:CRUD、分页、Specification、QueryDSL、Example。
|
||||||
|
|
||||||
|
### 4. 创建 Service
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class EmployeeService extends SimpleServiceSupport<Employee> {
|
||||||
|
public EmployeeService(EmployeeRepository repository) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
自动获得完整 CRUD 能力。
|
||||||
|
|
||||||
|
### 5. 创建 Controller(Web 应用)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("employee")
|
||||||
|
public class EmployeeController
|
||||||
|
extends SimpleControllerSupport<Employee, EmployeeController.SaveItem, EmployeeController.ListItem, EmployeeController.DetailItem> {
|
||||||
|
|
||||||
|
private final EmployeeService service;
|
||||||
|
|
||||||
|
public EmployeeController(EmployeeService service) {
|
||||||
|
super(service);
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<SaveItem, Employee> saveItemMapper() {
|
||||||
|
return item -> {
|
||||||
|
Employee entity = new Employee();
|
||||||
|
entity.setId(item.id());
|
||||||
|
entity.setName(item.name());
|
||||||
|
entity.setDepartmentId(item.departmentId());
|
||||||
|
entity.setEmail(item.email());
|
||||||
|
return entity;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<Employee, ListItem> listItemMapper() {
|
||||||
|
return entity -> new ListItem(
|
||||||
|
entity.getId(), entity.getName(), entity.getDepartmentId(),
|
||||||
|
entity.getEmail(), entity.getCreatedTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<Employee, DetailItem> detailItemMapper() {
|
||||||
|
return entity -> new DetailItem(
|
||||||
|
entity.getId(), entity.getName(), entity.getDepartmentId(),
|
||||||
|
entity.getEmail(), entity.getCreatedTime(), entity.getModifiedTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SaveItem(Long id, String name, Long departmentId, String email) {}
|
||||||
|
public record ListItem(Long id, String name, Long departmentId, String email, LocalDateTime createdTime) {}
|
||||||
|
public record DetailItem(Long id, String name, Long departmentId, String email, LocalDateTime createdTime, LocalDateTime modifiedTime) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
实现三个 Mapper:`saveItemMapper()`, `listItemMapper()`, `detailItemMapper()`
|
||||||
|
|
||||||
|
## 代码生成
|
||||||
|
|
||||||
|
```java
|
||||||
|
DatabaseHelper.generateBasicFiles(
|
||||||
|
"com.example.entity", // 实体包
|
||||||
|
"com.example", // 项目根包
|
||||||
|
"./src/main/java/com/example", // 源码路径
|
||||||
|
false // 是否覆盖
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 Repository、Service、Controller。
|
||||||
|
|
||||||
|
## API 接口(Web 应用)
|
||||||
|
|
||||||
|
### POST /{entity}/save
|
||||||
|
|
||||||
|
保存/更新实体。
|
||||||
|
|
||||||
|
请求(新增):
|
||||||
|
```json
|
||||||
|
{"name": "张三", "departmentId": 1, "email": "zhangsan@example.com"}
|
||||||
|
```
|
||||||
|
|
||||||
|
请求(更新):
|
||||||
|
```json
|
||||||
|
{"id": 123456789, "name": "李四"}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"status": 0, "message": "OK", "data": 123456789}
|
||||||
|
```
|
||||||
|
|
||||||
|
特性:不传 id 为新增,传 id 为更新(仅更新非 null 字段)。
|
||||||
|
|
||||||
|
### GET/POST /{entity}/list
|
||||||
|
|
||||||
|
GET:获取全部列表
|
||||||
|
|
||||||
|
POST:条件查询
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": {
|
||||||
|
"equal": {"departmentId": 1},
|
||||||
|
"like": {"name": "%张%"},
|
||||||
|
"greatEqual": {"createdTime": "2026-01-01 00:00:00"}
|
||||||
|
},
|
||||||
|
"sort": [{"column": "createdTime", "direction": "DESC"}],
|
||||||
|
"page": {"index": 1, "size": 20}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"status": 0, "message": "OK", "data": {"items": [...], "total": 100}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /{entity}/detail/{id}
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"status": 0, "message": "OK", "data": {"id": 123, "name": "张三", ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
ID 不存在返回 500。
|
||||||
|
|
||||||
|
### GET /{entity}/remove/{id}
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"status": 0, "message": "OK", "data": null}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查询条件
|
||||||
|
|
||||||
|
### Query 结构
|
||||||
|
|
||||||
|
```java
|
||||||
|
Query(
|
||||||
|
query: Queryable, // 查询条件
|
||||||
|
sort: List<Sortable>, // 排序
|
||||||
|
page: Pageable // 分页
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询操作
|
||||||
|
|
||||||
|
| 操作 | 类型 | 示例 |
|
||||||
|
|---|---|---|
|
||||||
|
| equal | Map | `{"name": "张三"}` |
|
||||||
|
| notEqual | Map | `{"status": "DELETED"}` |
|
||||||
|
| like | Map | `{"name": "%张%"}` |
|
||||||
|
| contain | Map | `{"name": "张"}` → `%张%` |
|
||||||
|
| startWith | Map | `{"name": "张"}` → `张%` |
|
||||||
|
| endWith | Map | `{"name": "三"}` → `%三` |
|
||||||
|
| great/greatEqual | Map | `{"age": 18}` |
|
||||||
|
| less/lessEqual | Map | `{"age": 60}` |
|
||||||
|
| between | Map | `{"age": {"start": 18, "end": 60}}` |
|
||||||
|
| inside | Map | `{"id": [1, 2, 3]}` |
|
||||||
|
| notInside | Map | `{"status": ["DELETED"]}` |
|
||||||
|
| nullEqual | List | `["deletedAt"]` |
|
||||||
|
| notNullEqual | List | `["email"]` |
|
||||||
|
|
||||||
|
### 排序
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"sort": [{"column": "createdTime", "direction": "DESC"}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
direction: `ASC` 升序,`DESC` 降序
|
||||||
|
|
||||||
|
### 分页
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"page": {"index": 1, "size": 20}}
|
||||||
|
```
|
||||||
|
|
||||||
|
index 从 1 开始,默认 `(1, 10)`,无排序默认 `createdTime DESC`
|
||||||
|
|
||||||
|
## 高级用法
|
||||||
|
|
||||||
|
### 扩展 Service
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
public class EmployeeService extends SimpleServiceSupport<Employee> {
|
||||||
|
private final EmployeeRepository repository;
|
||||||
|
|
||||||
|
public EmployeeService(EmployeeRepository repository) {
|
||||||
|
super(repository);
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义方法
|
||||||
|
public List<Employee> findByDepartmentId(Long departmentId) {
|
||||||
|
return repository.findAll(
|
||||||
|
(root, query, builder) -> builder.equal(root.get("departmentId"), departmentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局过滤条件
|
||||||
|
@Override
|
||||||
|
protected Predicate commonPredicates(Root<Employee> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
|
||||||
|
return builder.equal(root.get("deleted"), false); // 软删除过滤
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### QueryDSL 查询
|
||||||
|
|
||||||
|
```java
|
||||||
|
public List<Employee> findActiveEmployees() {
|
||||||
|
QEmployee q = QEmployee.employee;
|
||||||
|
return repository.findAll(q.status.eq("ACTIVE").and(q.deleted.isFalse()));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MapStruct Mapper
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Mapper
|
||||||
|
public interface EmployeeMapper {
|
||||||
|
Employee toEntity(SaveItem item);
|
||||||
|
ListItem toListItem(Employee entity);
|
||||||
|
DetailItem toDetailItem(Employee entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller 中使用
|
||||||
|
@Override
|
||||||
|
protected Function<SaveItem, Employee> saveItemMapper() {
|
||||||
|
return mapper::toEntity;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实体设计
|
||||||
|
|
||||||
|
### 字段类型
|
||||||
|
|
||||||
|
- ID: `Long`(雪花算法)
|
||||||
|
- 时间: `LocalDateTime`
|
||||||
|
- 金额: `BigDecimal`
|
||||||
|
- 枚举: Java enum(存储为字符串)
|
||||||
|
- 布尔: `Boolean`
|
||||||
|
|
||||||
|
### 关联关系
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
public class Order extends SimpleEntity {
|
||||||
|
@ManyToOne
|
||||||
|
@JoinColumn(name = "customer_id")
|
||||||
|
private Customer customer;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
|
||||||
|
private List<OrderItem> items;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:谨慎使用 `@OneToMany`,可能导致 N+1 问题。
|
||||||
|
|
||||||
|
### 索引
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Entity
|
||||||
|
@Table(name = "employee", indexes = {
|
||||||
|
@Index(name = "idx_department", columnList = "department_id")
|
||||||
|
})
|
||||||
|
public class Employee extends SimpleEntity { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工具类
|
||||||
|
|
||||||
|
### DatabaseHelper
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 生成 DDL
|
||||||
|
DatabaseHelper.generateDDL(
|
||||||
|
"com.example.entity", "./sql", MySQL8Dialect.class,
|
||||||
|
"jdbc:mysql://localhost:3306/test", "root", "password",
|
||||||
|
com.mysql.cj.jdbc.Driver.class
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### SnowflakeHelper
|
||||||
|
|
||||||
|
```java
|
||||||
|
Long id = SnowflakeHelper.next();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
**Q: 非 Web 应用如何使用?**
|
||||||
|
|
||||||
|
A: 不引入 web 依赖,创建 Entity → Repository → Service,直接注入 Service 使用。
|
||||||
|
|
||||||
|
**Q: 如何实现软删除?**
|
||||||
|
|
||||||
|
A: 添加 `deleted` 字段,重写 `commonPredicates()` 过滤,覆盖 `remove()` 改为更新。
|
||||||
|
|
||||||
|
**Q: 如何处理复杂查询?**
|
||||||
|
|
||||||
|
A: 使用 QueryDSL 或 Repository `@Query` 方法:
|
||||||
|
```java
|
||||||
|
@Query("SELECT e FROM Employee e WHERE e.departmentId = :deptId")
|
||||||
|
List<Employee> findByDepartment(@Param("deptId") Long deptId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Q: 如何批量插入?**
|
||||||
|
|
||||||
|
A: `service.save(entities)` 或 `repository.saveAll(entities)`
|
||||||
|
|
||||||
|
**Q: 查询条件支持关联对象吗?**
|
||||||
|
|
||||||
|
A: 支持,使用多级路径如 `"department.name"`
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. DTO 设计:SaveItem 可修改字段,ListItem 列表字段,DetailItem 完整字段
|
||||||
|
2. 事务:Service 方法已加事务,无需重复
|
||||||
|
3. 性能:列表查询避免关联对象,使用投影或 DTO
|
||||||
|
4. 代码生成:初期脚手架生成,后期手动调整
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
`src/test/java/.../integration/`
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
schema: spec-driven
|
schema: spec-driven
|
||||||
|
|
||||||
context: |
|
context: |
|
||||||
- maven管理的java项目,遵循java代码最佳实践规范
|
- **优先阅读README.md**获取项目结构与开发规范,所有代码风格、命名、注解、依赖、API等规范以README为准
|
||||||
- 交流、文档、注释、提交信息使用中文,代码命名使用英文
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
- 新增代码要遵循原有代码的设计风格和模式,优先考虑复用已有组件、工具、依赖库
|
- 涉及模块结构、API、实体等变更时同步更新README.md
|
||||||
- **优先阅读README.md**,README.md文档是项目的开发文档,记录代码结构和关键开发模式,优先读取获取上下文
|
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
- 涉及页面/路由/组件/功能模块变更或技术栈调整时,同步更新README.md
|
- 禁止创建git操作task
|
||||||
- Git提交: 仅中文; 格式为"类型: 简短描述",类型可选: feat(新功能)/fix(修复)/refactor(重构)/docs(文档)/style(格式)/test(测试)/chore(构建/工具); 多行描述空行后加详细说明; 禁创建git操作task
|
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||||
|
- 优先使用提问工具对用户进行提问
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
proposal:
|
proposal:
|
||||||
|
|||||||
@@ -11,6 +11,14 @@
|
|||||||
|
|
||||||
<artifactId>spring-boot-service-template-common</artifactId>
|
<artifactId>spring-boot-service-template-common</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.common.helper;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DisplayName("ObjectHelper Tests")
|
||||||
|
class ObjectHelperTest {
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("isNull/isNotNull")
|
||||||
|
class NullTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isNull with null object returns true")
|
||||||
|
void isNull_withNull_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isNull(null)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isNull with non-null object returns false")
|
||||||
|
void isNull_withNonNull_returnsFalse() {
|
||||||
|
assertThat(ObjectHelper.isNull(new Object())).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isNotNull with null object returns false")
|
||||||
|
void isNotNull_withNull_returnsFalse() {
|
||||||
|
assertThat(ObjectHelper.isNotNull(null)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isNotNull with non-null object returns true")
|
||||||
|
void isNotNull_withNonNull_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isNotNull(new Object())).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("isEmpty/isNotEmpty")
|
||||||
|
class EmptyTests {
|
||||||
|
|
||||||
|
static Stream<Arguments> emptyObjects() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of("null", null, true),
|
||||||
|
Arguments.of("empty collection", Collections.emptyList(), true),
|
||||||
|
Arguments.of("non-empty collection", List.of("a"), false),
|
||||||
|
Arguments.of("empty map", Collections.emptyMap(), true),
|
||||||
|
Arguments.of("non-empty map", Map.of("key", "value"), false),
|
||||||
|
Arguments.of("empty string", "", true),
|
||||||
|
Arguments.of("non-empty string", "text", false),
|
||||||
|
Arguments.of("empty char sequence", new StringBuilder(), true),
|
||||||
|
Arguments.of("non-empty char sequence", new StringBuilder("text"), false),
|
||||||
|
Arguments.of("empty object array", new Object[]{}, true),
|
||||||
|
Arguments.of("non-empty object array", new Object[]{"a"}, false),
|
||||||
|
Arguments.of("empty byte array", new byte[]{}, true),
|
||||||
|
Arguments.of("non-empty byte array", new byte[]{1}, false),
|
||||||
|
Arguments.of("empty short array", new short[]{}, true),
|
||||||
|
Arguments.of("non-empty short array", new short[]{1}, false),
|
||||||
|
Arguments.of("empty int array", new int[]{}, true),
|
||||||
|
Arguments.of("non-empty int array", new int[]{1}, false),
|
||||||
|
Arguments.of("empty long array", new long[]{}, true),
|
||||||
|
Arguments.of("non-empty long array", new long[]{1L}, false),
|
||||||
|
Arguments.of("empty float array", new float[]{}, true),
|
||||||
|
Arguments.of("non-empty float array", new float[]{1.0f}, false),
|
||||||
|
Arguments.of("empty double array", new double[]{}, true),
|
||||||
|
Arguments.of("non-empty double array", new double[]{1.0}, false),
|
||||||
|
Arguments.of("empty char array", new char[]{}, true),
|
||||||
|
Arguments.of("non-empty char array", new char[]{'a'}, false),
|
||||||
|
Arguments.of("empty boolean array", new boolean[]{}, true),
|
||||||
|
Arguments.of("non-empty boolean array", new boolean[]{true}, false),
|
||||||
|
Arguments.of("empty optional", Optional.empty(), true),
|
||||||
|
Arguments.of("non-empty optional", Optional.of("value"), false),
|
||||||
|
Arguments.of("non-empty object", new Object(), false)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0}")
|
||||||
|
@MethodSource("emptyObjects")
|
||||||
|
@DisplayName("isEmpty returns correct result")
|
||||||
|
void isEmpty_withVariousObjects_returnsCorrectResult(String description, Object obj, boolean expected) {
|
||||||
|
assertThat(ObjectHelper.isEmpty(obj)).isEqualTo(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest(name = "{0}")
|
||||||
|
@MethodSource("emptyObjects")
|
||||||
|
@DisplayName("isNotEmpty returns opposite of isEmpty")
|
||||||
|
void isNotEmpty_withVariousObjects_returnsCorrectResult(String description, Object obj, boolean expected) {
|
||||||
|
assertThat(ObjectHelper.isNotEmpty(obj)).isEqualTo(!expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("defaultIfNull")
|
||||||
|
class DefaultIfNullTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("defaultIfNull with null returns default value")
|
||||||
|
void defaultIfNull_withNull_returnsDefault() {
|
||||||
|
assertThat(ObjectHelper.defaultIfNull(null, "default")).isEqualTo("default");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("defaultIfNull with non-null returns original value")
|
||||||
|
void defaultIfNull_withNonNull_returnsOriginal() {
|
||||||
|
assertThat(ObjectHelper.defaultIfNull("value", "default")).isEqualTo("value");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("defaultIfNull with null and null default returns null")
|
||||||
|
void defaultIfNull_withBothNull_returnsNull() {
|
||||||
|
String result = ObjectHelper.defaultIfNull(null, null);
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("isComparable")
|
||||||
|
class ComparableTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with enum class returns true")
|
||||||
|
void isComparable_withEnum_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isComparable(TestEnum.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with String class returns true")
|
||||||
|
void isComparable_withString_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isComparable(String.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with Integer class returns true")
|
||||||
|
void isComparable_withInteger_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isComparable(Integer.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with primitive class returns true")
|
||||||
|
void isComparable_withPrimitive_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isComparable(int.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with non-comparable class returns false")
|
||||||
|
void isComparable_withNonComparable_returnsFalse() {
|
||||||
|
assertThat(ObjectHelper.isComparable(Object.class)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with null class returns false")
|
||||||
|
void isComparable_withNull_returnsFalse() {
|
||||||
|
Class<?> nullClass = null;
|
||||||
|
assertThat(ObjectHelper.isComparable(nullClass)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with enum object returns true")
|
||||||
|
void isComparable_withEnumObject_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isComparable(TestEnum.VALUE)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isComparable with null object returns false")
|
||||||
|
void isComparable_withNullObject_returnsFalse() {
|
||||||
|
Object nullObj = null;
|
||||||
|
assertThat(ObjectHelper.isComparable(nullObj)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("isCollection")
|
||||||
|
class CollectionTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with List class returns true")
|
||||||
|
void isCollection_withList_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isCollection(List.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with Set class returns true")
|
||||||
|
void isCollection_withSet_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isCollection(Set.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with non-collection class returns false")
|
||||||
|
void isCollection_withNonCollection_returnsFalse() {
|
||||||
|
assertThat(ObjectHelper.isCollection(String.class)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with null class returns false")
|
||||||
|
void isCollection_withNull_returnsFalse() {
|
||||||
|
Class<?> nullClass = null;
|
||||||
|
assertThat(ObjectHelper.isCollection(nullClass)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with ArrayList object returns true")
|
||||||
|
void isCollection_withArrayListObject_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isCollection(new ArrayList<>())).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isCollection with null object returns false")
|
||||||
|
void isCollection_withNullObject_returnsFalse() {
|
||||||
|
Object nullObj = null;
|
||||||
|
assertThat(ObjectHelper.isCollection(nullObj)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("isString")
|
||||||
|
class StringTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isString with String class returns true")
|
||||||
|
void isString_withStringClass_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isString(String.class)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isString with non-string class returns false")
|
||||||
|
void isString_withNonStringClass_returnsFalse() {
|
||||||
|
assertThat(ObjectHelper.isString(Integer.class)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isString with null class returns false")
|
||||||
|
void isString_withNullClass_returnsFalse() {
|
||||||
|
Class<?> nullClass = null;
|
||||||
|
assertThat(ObjectHelper.isString(nullClass)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isString with string object returns true")
|
||||||
|
void isString_withStringObject_returnsTrue() {
|
||||||
|
assertThat(ObjectHelper.isString("text")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isString with null object returns false")
|
||||||
|
void isString_withNullObject_returnsFalse() {
|
||||||
|
Object nullObj = null;
|
||||||
|
assertThat(ObjectHelper.isString(nullObj)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestEnum {
|
||||||
|
VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,31 @@
|
|||||||
<groupId>org.mapstruct</groupId>
|
<groupId>org.mapstruct</groupId>
|
||||||
<artifactId>mapstruct</artifactId>
|
<artifactId>mapstruct</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-data-jpa-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-webmvc-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.gavlyukovskiy</groupId>
|
||||||
|
<artifactId>p6spy-spring-boot-starter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.helper;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.RepeatedTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DisplayName("SnowflakeHelper Tests")
|
||||||
|
class SnowflakeHelperTest {
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("ID Generation")
|
||||||
|
class IdGenerationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("next returns positive number")
|
||||||
|
void next_returnsPositiveNumber() {
|
||||||
|
long id = SnowflakeHelper.next();
|
||||||
|
assertThat(id).isPositive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("next returns monotonically increasing IDs")
|
||||||
|
void next_returnsMonotonicallyIncreasingIds() {
|
||||||
|
long id1 = SnowflakeHelper.next();
|
||||||
|
long id2 = SnowflakeHelper.next();
|
||||||
|
long id3 = SnowflakeHelper.next();
|
||||||
|
|
||||||
|
assertThat(id2).isGreaterThan(id1);
|
||||||
|
assertThat(id3).isGreaterThan(id2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Uniqueness")
|
||||||
|
class UniquenessTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("batch generation produces unique IDs")
|
||||||
|
void batchGeneration_producesUniqueIds() {
|
||||||
|
int count = 10000;
|
||||||
|
Set<Long> ids = Collections.synchronizedSet(new HashSet<>());
|
||||||
|
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
ids.add(SnowflakeHelper.next());
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(ids).hasSize(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("concurrent generation produces unique IDs")
|
||||||
|
void concurrentGeneration_producesUniqueIds() throws InterruptedException {
|
||||||
|
int threadCount = 10;
|
||||||
|
int idsPerThread = 1000;
|
||||||
|
Set<Long> ids = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||||
|
CountDownLatch latch = new CountDownLatch(threadCount);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||||
|
|
||||||
|
for (int i = 0; i < threadCount; i++) {
|
||||||
|
executor.submit(() -> {
|
||||||
|
try {
|
||||||
|
for (int j = 0; j < idsPerThread; j++) {
|
||||||
|
ids.add(SnowflakeHelper.next());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
latch.await();
|
||||||
|
executor.shutdown();
|
||||||
|
|
||||||
|
assertThat(ids).hasSize(threadCount * idsPerThread);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Performance")
|
||||||
|
class PerformanceTests {
|
||||||
|
|
||||||
|
@RepeatedTest(5)
|
||||||
|
@DisplayName("generates 1000 IDs quickly")
|
||||||
|
void generatesIdsQuickly() {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
for (int i = 0; i < 1000; i++) {
|
||||||
|
SnowflakeHelper.next();
|
||||||
|
}
|
||||||
|
long elapsed = System.currentTimeMillis() - start;
|
||||||
|
assertThat(elapsed).isLessThan(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration;
|
||||||
|
|
||||||
|
import com.blinkfox.fenix.EnableFenix;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
@EnableFenix
|
||||||
|
@EnableJpaAuditing
|
||||||
|
public class IntegrationTestConfiguration {
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.Query;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class QueryBuilder {
|
||||||
|
private List<String> nullEqual;
|
||||||
|
private List<String> notNullEqual;
|
||||||
|
private List<String> empty;
|
||||||
|
private List<String> notEmpty;
|
||||||
|
private Map<String, ? extends Serializable> equal;
|
||||||
|
private Map<String, ? extends Serializable> notEqual;
|
||||||
|
private Map<String, String> like;
|
||||||
|
private Map<String, String> notLike;
|
||||||
|
private Map<String, String> contain;
|
||||||
|
private Map<String, String> notContain;
|
||||||
|
private Map<String, String> startWith;
|
||||||
|
private Map<String, String> notStartWith;
|
||||||
|
private Map<String, String> endWith;
|
||||||
|
private Map<String, String> notEndWith;
|
||||||
|
private Map<String, ? extends Serializable> great;
|
||||||
|
private Map<String, ? extends Serializable> less;
|
||||||
|
private Map<String, ? extends Serializable> greatEqual;
|
||||||
|
private Map<String, ? extends Serializable> lessEqual;
|
||||||
|
private Map<String, List<? extends Serializable>> inside;
|
||||||
|
private Map<String, List<? extends Serializable>> notInside;
|
||||||
|
private Map<String, Query.Queryable.Between> between;
|
||||||
|
private Map<String, Query.Queryable.Between> notBetween;
|
||||||
|
|
||||||
|
public QueryBuilder nullEqual(List<String> nullEqual) {
|
||||||
|
this.nullEqual = nullEqual;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notNullEqual(List<String> notNullEqual) {
|
||||||
|
this.notNullEqual = notNullEqual;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder empty(List<String> empty) {
|
||||||
|
this.empty = empty;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notEmpty(List<String> notEmpty) {
|
||||||
|
this.notEmpty = notEmpty;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder equal(Map<String, ? extends Serializable> equal) {
|
||||||
|
this.equal = equal;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notEqual(Map<String, ? extends Serializable> notEqual) {
|
||||||
|
this.notEqual = notEqual;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder like(Map<String, String> like) {
|
||||||
|
this.like = like;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notLike(Map<String, String> notLike) {
|
||||||
|
this.notLike = notLike;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder contain(Map<String, String> contain) {
|
||||||
|
this.contain = contain;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notContain(Map<String, String> notContain) {
|
||||||
|
this.notContain = notContain;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder startWith(Map<String, String> startWith) {
|
||||||
|
this.startWith = startWith;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notStartWith(Map<String, String> notStartWith) {
|
||||||
|
this.notStartWith = notStartWith;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder endWith(Map<String, String> endWith) {
|
||||||
|
this.endWith = endWith;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notEndWith(Map<String, String> notEndWith) {
|
||||||
|
this.notEndWith = notEndWith;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder great(Map<String, ? extends Serializable> great) {
|
||||||
|
this.great = great;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder less(Map<String, ? extends Serializable> less) {
|
||||||
|
this.less = less;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder greatEqual(Map<String, ? extends Serializable> greatEqual) {
|
||||||
|
this.greatEqual = greatEqual;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder lessEqual(Map<String, ? extends Serializable> lessEqual) {
|
||||||
|
this.lessEqual = lessEqual;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder inside(Map<String, List<? extends Serializable>> inside) {
|
||||||
|
this.inside = inside;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notInside(Map<String, List<? extends Serializable>> notInside) {
|
||||||
|
this.notInside = notInside;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder between(Map<String, Query.Queryable.Between> between) {
|
||||||
|
this.between = between;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryBuilder notBetween(Map<String, Query.Queryable.Between> notBetween) {
|
||||||
|
this.notBetween = notBetween;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Query build() {
|
||||||
|
return new Query(new Query.Queryable(
|
||||||
|
nullEqual, notNullEqual, empty, notEmpty, equal, notEqual,
|
||||||
|
like, notLike, contain, notContain, startWith, notStartWith,
|
||||||
|
endWith, notEndWith, great, less, greatEqual, lessEqual,
|
||||||
|
inside, notInside, between, notBetween
|
||||||
|
), null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.Query;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.controller.TestController;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.service.TestService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.AutoConfigureDataJpa;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(TestController.class)
|
||||||
|
@AutoConfigureDataJpa
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import({IntegrationTestConfiguration.class, TestService.class})
|
||||||
|
@DisplayName("SimpleControllerSupport Integration Tests")
|
||||||
|
class SimpleControllerSupportIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TestRepository repository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanUp() {
|
||||||
|
repository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("POST /save")
|
||||||
|
class SaveTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("save with valid data returns id")
|
||||||
|
void save_withValidData_returnsId() throws Exception {
|
||||||
|
var request = new TestController.SaveItem("张三", 25, TestStatus.ACTIVE, 5000.0, "java");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/test/save")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value(0))
|
||||||
|
.andExpect(jsonPath("$.message").value("OK"))
|
||||||
|
.andExpect(jsonPath("$.data").isNumber());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("saveItemMapper converts correctly")
|
||||||
|
void saveItemMapper_convertsCorrectly() throws Exception {
|
||||||
|
var request = new TestController.SaveItem("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring");
|
||||||
|
|
||||||
|
String response = mockMvc.perform(post("/test/save")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(request)))
|
||||||
|
.andReturn().getResponse().getContentAsString();
|
||||||
|
|
||||||
|
Long id = objectMapper.readTree(response).get("data").asLong();
|
||||||
|
TestEntity saved = repository.findById(id).orElseThrow();
|
||||||
|
|
||||||
|
assertThat(saved.getName()).isEqualTo("张三");
|
||||||
|
assertThat(saved.getAge()).isEqualTo(25);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(TestStatus.ACTIVE);
|
||||||
|
assertThat(saved.getSalary()).isEqualTo(5000.0);
|
||||||
|
assertThat(saved.getTags()).isEqualTo("java,spring");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("GET /list")
|
||||||
|
class ListTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list returns all entities")
|
||||||
|
void list_returnsAllEntities() throws Exception {
|
||||||
|
repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/test/list"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value(0))
|
||||||
|
.andExpect(jsonPath("$.message").value("OK"))
|
||||||
|
.andExpect(jsonPath("$.data.items").isArray())
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(2))
|
||||||
|
.andExpect(jsonPath("$.data.total").value(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("listItemMapper converts correctly")
|
||||||
|
void listItemMapper_convertsCorrectly() throws Exception {
|
||||||
|
var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/test/list"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items[0].id").value(entity.getId()))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].name").value("张三"))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].age").value(25))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].status").value("ACTIVE"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("POST /list")
|
||||||
|
class ListWithQueryTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list with query filters correctly")
|
||||||
|
void listWithQuery_filtersCorrectly() throws Exception {
|
||||||
|
var query = new QueryBuilder().equal(java.util.Map.of("name", "张三")).build();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/test/list")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(query)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].name").value("张三"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list with pagination returns correct page")
|
||||||
|
void listWithPagination_returnsCorrectPage() throws Exception {
|
||||||
|
var query = new Query(null, null, new Query.Pageable(1, 1));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/test/list")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(objectMapper.writeValueAsString(query)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items.length()").value(1))
|
||||||
|
.andExpect(jsonPath("$.data.total").value(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("GET /detail/{id}")
|
||||||
|
class DetailTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detail with existing id returns entity")
|
||||||
|
void detail_withExistingId_returnsEntity() throws Exception {
|
||||||
|
var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/test/detail/{id}", entity.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.id").value(entity.getId()))
|
||||||
|
.andExpect(jsonPath("$.data.name").value("张三"))
|
||||||
|
.andExpect(jsonPath("$.data.age").value(25))
|
||||||
|
.andExpect(jsonPath("$.data.status").value("ACTIVE"))
|
||||||
|
.andExpect(jsonPath("$.data.salary").value(5000.0))
|
||||||
|
.andExpect(jsonPath("$.data.tags").value("java,spring"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detailItemMapper converts correctly")
|
||||||
|
void detailItemMapper_convertsCorrectly() throws Exception {
|
||||||
|
var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/test/detail/{id}", entity.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.id").value(entity.getId()))
|
||||||
|
.andExpect(jsonPath("$.data.name").value("张三"))
|
||||||
|
.andExpect(jsonPath("$.data.tags").value("java,spring"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("GET /remove/{id}")
|
||||||
|
class RemoveTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("remove deletes entity")
|
||||||
|
void remove_deletesEntity() throws Exception {
|
||||||
|
var entity = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/test/remove/{id}", entity.getId()))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.status").value(0))
|
||||||
|
.andExpect(jsonPath("$.data").isEmpty());
|
||||||
|
|
||||||
|
assertThat(repository.findById(entity.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.Query;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.exception.IdNotFoundException;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.exception.NotCollectionException;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.exception.NotComparableException;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.exception.NotStringException;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.service.TestService;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import({IntegrationTestConfiguration.class, TestService.class})
|
||||||
|
@DisplayName("SimpleServiceSupport Integration Tests")
|
||||||
|
class SimpleServiceSupportIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TestService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TestRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
EntityManager entityManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanUp() {
|
||||||
|
repository.deleteAllInBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("CRUD Operations")
|
||||||
|
class CrudTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("save with new entity returns generated id")
|
||||||
|
void save_withNewEntity_returnsId() {
|
||||||
|
var entity = new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring");
|
||||||
|
|
||||||
|
Long id = service.save(entity);
|
||||||
|
|
||||||
|
assertThat(id).isNotNull().isPositive();
|
||||||
|
var saved = repository.findById(id).orElseThrow();
|
||||||
|
assertThat(saved.getName()).isEqualTo("张三");
|
||||||
|
assertThat(saved.getAge()).isEqualTo(25);
|
||||||
|
assertThat(saved.getStatus()).isEqualTo(TestStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("save with existing entity updates non-null fields")
|
||||||
|
void save_withExistingEntity_updatesFields() {
|
||||||
|
var entity = new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java");
|
||||||
|
Long id = service.save(entity);
|
||||||
|
|
||||||
|
var update = new TestEntity(null, 26, null, 6000.0, null);
|
||||||
|
update.setId(id);
|
||||||
|
service.save(update);
|
||||||
|
|
||||||
|
var updated = repository.findById(id).orElseThrow();
|
||||||
|
assertThat(updated.getName()).isEqualTo("张三");
|
||||||
|
assertThat(updated.getAge()).isEqualTo(26);
|
||||||
|
assertThat(updated.getStatus()).isEqualTo(TestStatus.ACTIVE);
|
||||||
|
assertThat(updated.getSalary()).isEqualTo(6000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("save multiple entities saves all")
|
||||||
|
void save_multipleEntities_savesAll() {
|
||||||
|
var entities = List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")
|
||||||
|
);
|
||||||
|
|
||||||
|
service.save(entities);
|
||||||
|
|
||||||
|
assertThat(repository.count()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("count returns correct total")
|
||||||
|
void count_returnsCorrectTotal() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")
|
||||||
|
));
|
||||||
|
|
||||||
|
Long count = service.count();
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list returns all entities")
|
||||||
|
void list_returnsAllEntities() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python")
|
||||||
|
));
|
||||||
|
|
||||||
|
List<TestEntity> result = service.list();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list with ids returns matching entities")
|
||||||
|
void list_withIds_returnsMatching() {
|
||||||
|
var e1 = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
var e2 = repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"));
|
||||||
|
repository.save(new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go"));
|
||||||
|
|
||||||
|
List<TestEntity> result = service.list(Set.of(e1.getId(), e2.getId()));
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
.extracting(TestEntity::getName)
|
||||||
|
.containsExactlyInAnyOrder("张三", "李四");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detail with existing id returns entity")
|
||||||
|
void detail_withExistingId_returnsEntity() {
|
||||||
|
var saved = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
TestEntity result = service.detail(saved.getId());
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(result.getName()).isEqualTo("张三");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detail with non-existing id returns null")
|
||||||
|
void detail_withNonExistingId_returnsNull() {
|
||||||
|
TestEntity result = service.detail(999L);
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detailOrThrow with non-existing id throws exception")
|
||||||
|
void detailOrThrow_withNonExistingId_throwsException() {
|
||||||
|
assertThatThrownBy(() -> service.detailOrThrow(999L))
|
||||||
|
.isInstanceOf(IdNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("remove deletes entity")
|
||||||
|
void remove_deletesEntity() {
|
||||||
|
var saved = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
repository.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
service.remove(saved.getId());
|
||||||
|
repository.flush();
|
||||||
|
|
||||||
|
var nativeQuery = entityManager.createNativeQuery("SELECT COUNT(*) FROM test_entity WHERE id = ?");
|
||||||
|
nativeQuery.setParameter(1, saved.getId());
|
||||||
|
var count = ((Number) nativeQuery.getSingleResult()).longValue();
|
||||||
|
assertThat(count).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("remove multiple entities deletes all")
|
||||||
|
void remove_multipleEntities_deletesAll() {
|
||||||
|
var e1 = repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
var e2 = repository.save(new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"));
|
||||||
|
|
||||||
|
service.remove(Set.of(e1.getId(), e2.getId()));
|
||||||
|
repository.flush();
|
||||||
|
|
||||||
|
assertThat(repository.count()).isZero();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Equality Conditions")
|
||||||
|
class EqualityConditionTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, null),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("equal condition filters correctly")
|
||||||
|
void equalCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().equal(Map.of("name", "张三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getName()).isEqualTo("张三");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notEqual condition filters correctly")
|
||||||
|
void notEqualCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notEqual(Map.of("name", "张三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).noneMatch(e -> "张三".equals(e.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("nullEqual condition filters null values")
|
||||||
|
void nullEqualCondition_filtersNullValues() {
|
||||||
|
var query = new QueryBuilder().nullEqual(List.of("tags")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getTags()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notNullEqual condition filters non-null values")
|
||||||
|
void notNullEqualCondition_filtersNonNullValues() {
|
||||||
|
var query = new QueryBuilder().notNullEqual(List.of("tags")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getTags() != null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("String Conditions")
|
||||||
|
class StringConditionTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java,spring"),
|
||||||
|
new TestEntity("李四三", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("like condition filters correctly")
|
||||||
|
void likeCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().like(Map.of("name", "%三%")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notLike condition filters correctly")
|
||||||
|
void notLikeCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notLike(Map.of("name", "%三%")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getName()).isEqualTo("王五");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("contain condition filters correctly")
|
||||||
|
void containCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().contain(Map.of("name", "三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notContain condition filters correctly")
|
||||||
|
void notContainCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notContain(Map.of("name", "三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("startWith condition filters correctly")
|
||||||
|
void startWithCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().startWith(Map.of("name", "张")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getName()).isEqualTo("张三");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notStartWith condition filters correctly")
|
||||||
|
void notStartWithCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notStartWith(Map.of("name", "张")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).noneMatch(e -> e.getName().startsWith("张"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("endWith condition filters correctly")
|
||||||
|
void endWithCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().endWith(Map.of("name", "三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notEndWith condition filters correctly")
|
||||||
|
void notEndWithCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notEndWith(Map.of("name", "三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getName()).isEqualTo("王五");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Comparison Conditions")
|
||||||
|
class ComparisonConditionTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 35, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 45, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("great condition filters correctly")
|
||||||
|
void greatCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().great(Map.of("age", 30)).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getAge() > 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("less condition filters correctly")
|
||||||
|
void lessCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().less(Map.of("age", 40)).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getAge() < 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("greatEqual condition filters correctly")
|
||||||
|
void greatEqualCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().greatEqual(Map.of("age", 35)).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getAge() >= 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("lessEqual condition filters correctly")
|
||||||
|
void lessEqualCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().lessEqual(Map.of("age", 35)).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getAge() <= 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("between condition filters correctly")
|
||||||
|
void betweenCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().between(Map.of("age", new Query.Queryable.Between(25, 35))).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getAge() >= 25 && e.getAge() <= 35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notBetween condition filters correctly")
|
||||||
|
void notBetweenCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().notBetween(Map.of("age", new Query.Queryable.Between(26, 44))).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Collection Conditions")
|
||||||
|
class CollectionConditionTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("inside condition filters correctly")
|
||||||
|
void insideCondition_filtersCorrectly() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().inside(Map.of("age", List.of(25, 35))).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notInside condition filters correctly")
|
||||||
|
void notInsideCondition_filtersCorrectly() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().notInside(Map.of("age", List.of(25, 35))).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getAge()).isEqualTo(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("empty condition filters correctly")
|
||||||
|
void emptyCondition_filtersCorrectly() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().empty(List.of("tags")).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.list(query))
|
||||||
|
.isInstanceOf(NotCollectionException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("notEmpty condition filters correctly")
|
||||||
|
void notEmptyCondition_filtersCorrectly() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().notEmpty(List.of("tags")).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.list(query))
|
||||||
|
.isInstanceOf(NotCollectionException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Enum Condition")
|
||||||
|
class EnumConditionTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.DELETED, 7000.0, "go")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("enum condition filters correctly")
|
||||||
|
void enumCondition_filtersCorrectly() {
|
||||||
|
var query = new QueryBuilder().equal(Map.of("status", "ACTIVE")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(1);
|
||||||
|
assertThat(result.items().get(0).getStatus()).isEqualTo(TestStatus.ACTIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Combined Conditions")
|
||||||
|
class CombinedConditionTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"),
|
||||||
|
new TestEntity("李四", 30, TestStatus.INACTIVE, 6000.0, "python"),
|
||||||
|
new TestEntity("王五", 35, TestStatus.ACTIVE, 7000.0, "go"),
|
||||||
|
new TestEntity("赵六", 40, TestStatus.ACTIVE, 8000.0, "rust")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("combined conditions filter correctly")
|
||||||
|
void combinedConditions_filterCorrectly() {
|
||||||
|
var query = new QueryBuilder()
|
||||||
|
.equal(Map.of("status", "ACTIVE"))
|
||||||
|
.great(Map.of("age", 30))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(2);
|
||||||
|
assertThat(result.items()).allMatch(e -> e.getStatus() == TestStatus.ACTIVE && e.getAge() > 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Pagination and Sorting")
|
||||||
|
class PaginationSortingTests {
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
repository.deleteAllInBatch();
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
for (int i = 1; i <= 25; i++) {
|
||||||
|
repository.save(new TestEntity("用户" + i, 20 + i, TestStatus.ACTIVE, 5000.0 + i * 100, "tag" + i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("default pagination returns first page with 10 items")
|
||||||
|
void defaultPagination_returnsFirstPage() {
|
||||||
|
var query = new Query(null, null, null);
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(10);
|
||||||
|
assertThat(result.total()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("custom pagination returns correct page")
|
||||||
|
void customPagination_returnsCorrectPage() {
|
||||||
|
var query = new Query(null, null, new Query.Pageable(1, 5));
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(5);
|
||||||
|
assertThat(result.total()).isEqualTo(25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("custom sorting sorts correctly")
|
||||||
|
void customSorting_sortsCorrectly() {
|
||||||
|
var query = new Query(null, List.of(new Query.Sortable("age", Query.Sortable.Direction.ASC)), new Query.Pageable(1, 10));
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(10);
|
||||||
|
assertThat(result.items().get(0).getAge()).isEqualTo(21);
|
||||||
|
assertThat(result.items()).isSortedAccordingTo((a, b) -> a.getAge().compareTo(b.getAge()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("multiple sort fields sort correctly")
|
||||||
|
void multipleSortFields_sortCorrectly() {
|
||||||
|
repository.deleteAllInBatch();
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
repository.saveAll(List.of(
|
||||||
|
new TestEntity("A", 20, TestStatus.ACTIVE, 5000.0, null),
|
||||||
|
new TestEntity("B", 30, TestStatus.ACTIVE, 3000.0, null),
|
||||||
|
new TestEntity("C", 30, TestStatus.ACTIVE, 4000.0, null)
|
||||||
|
));
|
||||||
|
|
||||||
|
var query = new Query(null, List.of(
|
||||||
|
new Query.Sortable("age", Query.Sortable.Direction.ASC),
|
||||||
|
new Query.Sortable("salary", Query.Sortable.Direction.DESC)
|
||||||
|
), new Query.Pageable(1, 10));
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).hasSize(3);
|
||||||
|
assertThat(result.items().get(0).getName()).isEqualTo("A");
|
||||||
|
assertThat(result.items().get(1).getName()).isEqualTo("C");
|
||||||
|
assertThat(result.items().get(2).getName()).isEqualTo("B");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Exception Handling")
|
||||||
|
class ExceptionTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("like on non-string field throws NotStringException")
|
||||||
|
void likeOnNonStringField_throwsNotStringException() {
|
||||||
|
repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().like(Map.of("age", "%5%")).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.list(query))
|
||||||
|
.isInstanceOf(NotStringException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("great on enum field with enum value throws exception")
|
||||||
|
void greatOnEnumField_withEnumValue_throwsException() {
|
||||||
|
repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().great(Map.of("status", TestStatus.ACTIVE)).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.list(query))
|
||||||
|
.isInstanceOf(org.springframework.dao.InvalidDataAccessApiUsageException.class)
|
||||||
|
.hasMessageContaining("枚举类型字段需要 String 类型的值");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("empty on non-collection field throws NotCollectionException")
|
||||||
|
void emptyOnNonCollectionField_throwsNotCollectionException() {
|
||||||
|
repository.save(new TestEntity("张三", 25, TestStatus.ACTIVE, 5000.0, "java"));
|
||||||
|
|
||||||
|
var query = new QueryBuilder().empty(List.of("name")).build();
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.list(query))
|
||||||
|
.isInstanceOf(NotCollectionException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.repository.TestSoftDeleteRepository;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.service.TestSoftDeleteService;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import({IntegrationTestConfiguration.class, TestSoftDeleteService.class})
|
||||||
|
@DisplayName("@SoftDelete Integration Tests")
|
||||||
|
class SoftDeleteIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TestSoftDeleteService service;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
TestSoftDeleteRepository repository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
EntityManager entityManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanUp() {
|
||||||
|
repository.deleteAllInBatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Save and Delete Behavior")
|
||||||
|
class SaveDeleteTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("save entity sets deleted to false")
|
||||||
|
void saveEntity_setsDeletedFalse() {
|
||||||
|
var entity = new TestSoftDeleteEntity("张三", 25);
|
||||||
|
|
||||||
|
Long id = service.save(entity);
|
||||||
|
|
||||||
|
var saved = repository.findById(id).orElseThrow();
|
||||||
|
assertThat(saved.getName()).isEqualTo("张三");
|
||||||
|
|
||||||
|
var nativeQuery = entityManager.createNativeQuery("SELECT deleted FROM test_soft_delete_entity WHERE id = ?");
|
||||||
|
nativeQuery.setParameter(1, id);
|
||||||
|
var deleted = (Boolean) nativeQuery.getSingleResult();
|
||||||
|
assertThat(deleted).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("remove sets deleted to true")
|
||||||
|
void remove_setsDeletedTrue() {
|
||||||
|
var entity = new TestSoftDeleteEntity("张三", 25);
|
||||||
|
Long id = service.save(entity);
|
||||||
|
repository.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
service.remove(id);
|
||||||
|
repository.flush();
|
||||||
|
|
||||||
|
var nativeQuery = entityManager.createNativeQuery("SELECT deleted FROM test_soft_delete_entity WHERE id = ?");
|
||||||
|
nativeQuery.setParameter(1, id);
|
||||||
|
var deleted = (Boolean) nativeQuery.getSingleResult();
|
||||||
|
assertThat(deleted).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("batch remove sets deleted to true")
|
||||||
|
void batchRemove_setsDeletedTrue() {
|
||||||
|
var e1 = new TestSoftDeleteEntity("张三", 25);
|
||||||
|
var e2 = new TestSoftDeleteEntity("李四", 30);
|
||||||
|
Long id1 = service.save(e1);
|
||||||
|
Long id2 = service.save(e2);
|
||||||
|
|
||||||
|
service.remove(Set.of(id1, id2));
|
||||||
|
|
||||||
|
var nativeQuery = entityManager.createNativeQuery("SELECT COUNT(*) FROM test_soft_delete_entity WHERE deleted = true");
|
||||||
|
var count = ((Number) nativeQuery.getSingleResult()).longValue();
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
|
||||||
|
assertThat(repository.findAll()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Query Behavior")
|
||||||
|
class QueryTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("list filters out deleted entities")
|
||||||
|
void list_filtersDeletedEntities() {
|
||||||
|
var e1 = repository.save(new TestSoftDeleteEntity("张三", 25));
|
||||||
|
repository.save(new TestSoftDeleteEntity("李四", 30));
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?")
|
||||||
|
.setParameter(1, e1.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
List<TestSoftDeleteEntity> result = service.list();
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getName()).isEqualTo("李四");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("count excludes deleted entities")
|
||||||
|
void count_excludesDeletedEntities() {
|
||||||
|
var e1 = repository.save(new TestSoftDeleteEntity("张三", 25));
|
||||||
|
repository.save(new TestSoftDeleteEntity("李四", 30));
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?")
|
||||||
|
.setParameter(1, e1.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Long count = service.count();
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("detail returns null for deleted entity")
|
||||||
|
void detail_returnsNullForDeletedEntity() {
|
||||||
|
var entity = repository.save(new TestSoftDeleteEntity("张三", 25));
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?")
|
||||||
|
.setParameter(1, entity.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
TestSoftDeleteEntity result = service.detail(entity.getId());
|
||||||
|
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("query with conditions filters deleted entities")
|
||||||
|
void queryWithConditions_filtersDeletedEntities() {
|
||||||
|
var e1 = repository.save(new TestSoftDeleteEntity("张三", 25));
|
||||||
|
repository.save(new TestSoftDeleteEntity("李四", 30));
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?")
|
||||||
|
.setParameter(1, e1.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
var query = new QueryBuilder().equal(Map.of("name", "张三")).build();
|
||||||
|
|
||||||
|
var result = service.list(query);
|
||||||
|
|
||||||
|
assertThat(result.items()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Restore Behavior")
|
||||||
|
class RestoreTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("restore deleted entity makes it visible again")
|
||||||
|
void restore_makesEntityVisible() {
|
||||||
|
var entity = repository.save(new TestSoftDeleteEntity("张三", 25));
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = true WHERE id = ?")
|
||||||
|
.setParameter(1, entity.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(service.list()).isEmpty();
|
||||||
|
|
||||||
|
entityManager.createNativeQuery("UPDATE test_soft_delete_entity SET deleted = false WHERE id = ?")
|
||||||
|
.setParameter(1, entity.getId())
|
||||||
|
.executeUpdate();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
List<TestSoftDeleteEntity> result = service.list();
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getName()).isEqualTo("张三");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.controller;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestStatus;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.service.TestService;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.controller.SimpleControllerSupport;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/test")
|
||||||
|
public class TestController extends SimpleControllerSupport<TestEntity, TestController.SaveItem, TestController.ListItem, TestController.DetailItem> {
|
||||||
|
public TestController(TestService service) {
|
||||||
|
super(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<SaveItem, TestEntity> saveItemMapper() {
|
||||||
|
return item -> new TestEntity(item.name, item.age, item.status, item.salary, item.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<TestEntity, ListItem> listItemMapper() {
|
||||||
|
return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge(), entity.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<TestEntity, DetailItem> detailItemMapper() {
|
||||||
|
return entity -> new DetailItem(entity.getId(), entity.getName(), entity.getAge(), entity.getStatus(), entity.getSalary(), entity.getTags());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SaveItem(String name, Integer age, TestStatus status, Double salary, String tags) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ListItem(Long id, String name, Integer age, TestStatus status) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DetailItem(Long id, String name, Integer age, TestStatus status, Double salary, String tags) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.controller;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.service.TestSoftDeleteService;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.controller.SimpleControllerSupport;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/test-soft-delete")
|
||||||
|
public class TestSoftDeleteController extends SimpleControllerSupport<TestSoftDeleteEntity, TestSoftDeleteController.SaveItem, TestSoftDeleteController.ListItem, TestSoftDeleteController.DetailItem> {
|
||||||
|
public TestSoftDeleteController(TestSoftDeleteService service) {
|
||||||
|
super(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<SaveItem, TestSoftDeleteEntity> saveItemMapper() {
|
||||||
|
return item -> new TestSoftDeleteEntity(item.name, item.age);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<TestSoftDeleteEntity, ListItem> listItemMapper() {
|
||||||
|
return entity -> new ListItem(entity.getId(), entity.getName(), entity.getAge());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Function<TestSoftDeleteEntity, DetailItem> detailItemMapper() {
|
||||||
|
return entity -> new DetailItem(entity.getId(), entity.getName(), entity.getAge());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SaveItem(String name, Integer age) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ListItem(Long id, String name, Integer age) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DetailItem(Long id, String name, Integer age) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.entity;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.SimpleEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.SnowflakeId;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "test_entity")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ToString
|
||||||
|
@FieldNameConstants
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TestEntity extends SimpleEntity {
|
||||||
|
@SnowflakeId
|
||||||
|
@Column(comment = "名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(comment = "年龄")
|
||||||
|
private Integer age;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(comment = "状态")
|
||||||
|
private TestStatus status;
|
||||||
|
|
||||||
|
@Column(comment = "薪资")
|
||||||
|
private Double salary;
|
||||||
|
|
||||||
|
@Column(comment = "标签")
|
||||||
|
private String tags;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.entity;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.SimpleEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.entity.SnowflakeId;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
import lombok.experimental.FieldNameConstants;
|
||||||
|
import org.hibernate.annotations.SoftDelete;
|
||||||
|
import org.hibernate.annotations.SoftDeleteType;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "test_soft_delete_entity")
|
||||||
|
@SoftDelete(strategy = SoftDeleteType.DELETED)
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@ToString
|
||||||
|
@FieldNameConstants
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TestSoftDeleteEntity extends SimpleEntity {
|
||||||
|
@SnowflakeId
|
||||||
|
@Column(comment = "名称")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(comment = "年龄")
|
||||||
|
private Integer age;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.entity;
|
||||||
|
|
||||||
|
public enum TestStatus {
|
||||||
|
ACTIVE,
|
||||||
|
INACTIVE,
|
||||||
|
DELETED
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.repository;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.repository.SimpleRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TestRepository extends SimpleRepository<TestEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.repository;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.repository.SimpleRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface TestSoftDeleteRepository extends SimpleRepository<TestSoftDeleteEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.service;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.repository.TestRepository;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.service.SimpleServiceSupport;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TestService extends SimpleServiceSupport<TestEntity> {
|
||||||
|
public TestService(TestRepository repository) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.lanyuanxiaoyao.service.template.database.integration.service;
|
||||||
|
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.entity.TestSoftDeleteEntity;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.integration.repository.TestSoftDeleteRepository;
|
||||||
|
import com.lanyuanxiaoyao.service.template.database.service.SimpleServiceSupport;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class TestSoftDeleteService extends SimpleServiceSupport<TestSoftDeleteEntity> {
|
||||||
|
public TestSoftDeleteService(TestSoftDeleteRepository repository) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
|
||||||
|
decorator:
|
||||||
|
datasource:
|
||||||
|
p6spy:
|
||||||
|
logging: slf4j
|
||||||
|
log-format: "%(executionTime) ms | %(sqlSingleLine)"
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
p6spy: INFO
|
||||||
Reference in New Issue
Block a user