1
This commit is contained in:
7
1.sql
Normal file
7
1.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
SELECT
|
||||
book.title AS book.title,
|
||||
book$periodical.id AS book$periodical$id,
|
||||
book$periodical.name AS book$periodical$name,
|
||||
book$periodical.publish_month AS book$periodical$publish_month
|
||||
FROM book book
|
||||
LEFT JOIN periodical book$periodical ON book.publisher_id = book$periodical.publisher_id AND book.publish_month = book$periodical.publish_month
|
||||
49
AI_CONTEXT.md
Normal file
49
AI_CONTEXT.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# AI Context: Dynamic Query & Low-Code Engine
|
||||
|
||||
## 1. Project Overview
|
||||
This project explores two distinct approaches for building a "Low-Code" style backend that supports dynamic field selection, complex filtering, and deep graph traversal without writing boilerplate code.
|
||||
|
||||
## 2. Core Architectures
|
||||
|
||||
### A. Hibernate/JPA Strategy (Runtime Dynamic)
|
||||
* **Goal**: Fully dynamic query construction at runtime.
|
||||
* **Key Components**:
|
||||
* `BaseRepository<T, ID>`: Extends `JpaRepository` & `JpaSpecificationExecutor`.
|
||||
* `BaseController<T>`: Provides generic `/list` endpoint.
|
||||
* `GenericSpecification`: Converts Map params to JPA Predicates.
|
||||
* Supports exact match (default).
|
||||
* Supports LIKE match via wildcard `*` (e.g., `name=John*`).
|
||||
* `Squiggly`: Used for dynamic JSON field projection (Graph-trimming) *after* DB fetch.
|
||||
* **Pros**: Zero code generation, extremely flexible, standard JPA ecosystem.
|
||||
* **Cons**: "N+1" issues if not careful (Entity Graph needed for optimization), limited by JPA's performance on complex joins.
|
||||
|
||||
### B. MyBatis XML Generation Strategy (Compile/Runtime Generation)
|
||||
* **Goal**: Generate optimized, static-like SQL/XML artifacts for high performance and complex mapping.
|
||||
* **Key Components**:
|
||||
* `MyBatisGeneratorUtils`: The core engine.
|
||||
* **Mechanism**:
|
||||
1. **ResultMap Generation**:
|
||||
* Generates standard `<resultMap>` for each involved Entity.
|
||||
* Uses `columnPrefix` (e.g., `author$`, `books$`) to link nested associations/collections recursively.
|
||||
* This allows infinite recursion (Book -> Author -> Book) using the same set of ResultMaps.
|
||||
2. **SQL Generation**:
|
||||
* Builds a Join Tree based on requested fields (e.g., `["title", "author.books.title"]`).
|
||||
* Generates `LEFT JOIN`s automatically.
|
||||
* **Smart Alias Strategy**:
|
||||
* Table Alias: Full path (e.g., `book$author$books`) to avoid collisions.
|
||||
* Column Alias: `path$ColumnName` (e.g., `author$books$publish_month`).
|
||||
* **Crucial**: The last part of the alias MUST be the DB Column Name (not Java Field Name) to match `<result column="...">` during prefix stripping.
|
||||
3. **One-To-Many Handling**:
|
||||
* Detects `@OneToMany(mappedBy=...)` to generate correct `ON parent.id = child.parent_id` join condition.
|
||||
* **Forced ID Projection**: Automatically selects `id` columns for all nodes to ensure correct MyBatis collection folding.
|
||||
* **Pros**: High performance (single SQL), solves N+1, precise column selection.
|
||||
* **Cons**: Requires generation step (or runtime string manipulation), more complex engine logic.
|
||||
|
||||
## 3. Key Files & Locations
|
||||
* `com.example.demo.util.MyBatisGeneratorUtils`: The "Brain" of the MyBatis strategy.
|
||||
* `com.example.demo.repository.GenericSpecification`: The "Brain" of the JPA strategy.
|
||||
* `com.example.demo.controller.BaseController`: The generic entry point.
|
||||
|
||||
## 4. Current Status
|
||||
* JPA Strategy: Fully functional. Read operations support wildcard search and dynamic projection. Write operations support cascading creation and deep-merge patching.
|
||||
* MyBatis Strategy: Engine is complete and tested (`MyBatisGeneratorUtilsTest`). Capable of generating full valid XML with recursive mappings and List support. Integration into Controller is the next logical step.
|
||||
129
README.md
129
README.md
@@ -1,40 +1,111 @@
|
||||
## 运行指南
|
||||
# Java Low-Code / Dynamic Query Engine
|
||||
|
||||
这是一个使用 Spring Boot, SQLite, Hibernate (JPA) 和 MyBatis 的示例项目。
|
||||
本项目展示了两种在 Java (Spring Boot) 环境下实现“低代码”后端查询引擎的方案。这两种方案都旨在解决同一个问题:**前端按需索取数据(GraphQL 风格),后端自动构建查询,无需手动编写 CRUD 代码。**
|
||||
|
||||
### 1. 启动应用
|
||||
## 方案对比
|
||||
|
||||
在项目根目录下运行:
|
||||
| 特性 | 方案 A: Hibernate/JPA + Squiggly | 方案 B: MyBatis 动态生成引擎 |
|
||||
| :--- | :--- | :--- |
|
||||
| **核心理念** | 运行时动态构建 Predicate + JSON 裁剪 | 自动生成优化的 SQL 和 ResultMap XML |
|
||||
| **查询构建** | JPA Criteria API | 字符串拼接 / AST 构建 SQL |
|
||||
| **字段裁剪** | 查全量数据 -> 内存中 JSON 裁剪 (Squiggly) | 数据库层直接只查所需字段 (Select Partial) |
|
||||
| **关联查询** | 依赖 Hibernate 懒加载 (可能导致 N+1) | 单条 SQL LEFT JOIN (性能最优) |
|
||||
| **递归/深层** | 容易 StackOverflow (需 `@JsonIdentityInfo`) | 通过 ResultMap + ColumnPrefix 完美支持无限递归 |
|
||||
| **适用场景** | 快速开发、中后台管理、逻辑简单 | 高性能 API、复杂报表、移动端接口 |
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run
|
||||
---
|
||||
|
||||
## 方案 A: Hibernate/JPA 策略
|
||||
|
||||
利用 Spring Data JPA 的 `Specification` 和 Jackson 的过滤器实现。
|
||||
|
||||
### 特性
|
||||
1. **通用 Controller/Repository**:只需继承 `BaseController` 和 `BaseRepository`,即可获得完整的 CRUD 和动态搜索能力。
|
||||
2. **动态过滤**:支持 `key=value` 精确匹配和 `key=val*` 模糊匹配。
|
||||
3. **动态投影**:通过 `fields=id,title,author.name` 参数,后端自动裁剪返回的 JSON 结构。
|
||||
4. **全能写操作**:
|
||||
* **Create (POST)**: 支持级联创建(如一次性提交 Book + Author)。
|
||||
* **Update (PUT)**: 全量更新。
|
||||
* **Patch (PATCH)**: 支持**深度合并 (Deep Merge)**,仅更新 JSON 中提供的字段,保留嵌套对象的其他属性。
|
||||
* **Delete (DELETE)**: 支持级联删除。
|
||||
|
||||
### 代码示例
|
||||
```java
|
||||
// 继承通用基类
|
||||
public interface BookRepository extends BaseRepository<Book, Long> {}
|
||||
|
||||
// 1. 动态查询
|
||||
// GET /books?author.name=John*&fields=title,author.name
|
||||
|
||||
// 2. 级联创建 (POST /books)
|
||||
/*
|
||||
{
|
||||
"title": "New Book",
|
||||
"author": { "name": "New Author" } // 同时创建 Author
|
||||
}
|
||||
*/
|
||||
|
||||
// 3. 局部更新 (PATCH /books/1)
|
||||
// 仅修改标题,Author 关联保持不变
|
||||
/*
|
||||
{ "title": "Updated Title" }
|
||||
*/
|
||||
```
|
||||
或者打包运行:
|
||||
```bash
|
||||
mvn clean package -DskipTests
|
||||
java -jar target/demo-sqlite-mybatis-hibernate-0.0.1-SNAPSHOT.jar
|
||||
|
||||
---
|
||||
|
||||
## 方案 B: MyBatis 动态生成引擎
|
||||
|
||||
这是本项目最核心的创新点。我们要解决 MyBatis **"手写 XML 太累,自动生成又不灵活"** 的痛点。
|
||||
|
||||
我们实现了一个**运行时 XML 生成器** (`MyBatisGeneratorUtils`),它能根据请求动态生成完整的 MyBatis Mapper XML。
|
||||
|
||||
### 核心技术点
|
||||
|
||||
1. **递归 ResultMap 复用**:
|
||||
利用 MyBatis 的 `columnPrefix` 特性,我们只需要定义一套标准的 ResultMap(如 `BookMap`, `AuthorMap`),就可以通过前缀(`author$`, `books$`)实现无限层级的嵌套映射。
|
||||
|
||||
2. **智能别名策略**:
|
||||
生成的 SQL 别名严格遵循 `path$ColumnName` 格式(例如 `book$author$publish_month`)。MyBatis 在处理 ResultMap 时会逐层剥离前缀,最终将 `publish_month` 映射到正确的实体属性上。这解决了递归查询中列名冲突和映射失效的问题。
|
||||
|
||||
3. **一对多 (List) 自动折叠**:
|
||||
引擎会自动识别 `@OneToMany` 关系,生成正确的 `JOIN` 条件,并**强制投影 ID 列**。这确保了 MyBatis 能够正确地进行结果集去重(Result Folding),将多行数据合并为一个对象列表。
|
||||
|
||||
### 生成效果示例
|
||||
|
||||
**输入**:查询书籍,要求返回 `title` 和 `author.books.title`。
|
||||
|
||||
**自动生成的 SQL**:
|
||||
```sql
|
||||
SELECT
|
||||
book.id AS id,
|
||||
book.title AS title,
|
||||
book$author.id AS author$id,
|
||||
book$author$books.id AS author$books$id,
|
||||
book$author$books.title AS author$books$title
|
||||
FROM book book
|
||||
LEFT JOIN author book$author ON book.author_id = book$author.id
|
||||
LEFT JOIN book book$author$books ON book$author.id = book$author$books.author_id
|
||||
```
|
||||
*注意:端口配置在 8081*
|
||||
|
||||
### 2. 测试接口
|
||||
**自动生成的 XML**(片段):
|
||||
```xml
|
||||
<resultMap id="AuthorMap" type="Author">
|
||||
<!-- 引用 BookMap,前缀为 books$ -->
|
||||
<collection property="books" resultMap="BookMap" columnPrefix="books$"/>
|
||||
</resultMap>
|
||||
```
|
||||
|
||||
项目启动时会自动插入一些测试数据。你可以使用以下接口进行测试:
|
||||
### 如何使用
|
||||
目前引擎逻辑位于 `com.example.demo.util.MyBatisGeneratorUtils`,并配有完整的单元测试 `MyBatisGeneratorUtilsTest`。下一步计划是将其封装为 `MyBatisDynamicService`,在 Controller 中直接调用。
|
||||
|
||||
- **获取所有书籍 (使用 JPA):**
|
||||
`GET http://localhost:8081/books/jpa`
|
||||
---
|
||||
|
||||
- **获取所有书籍 (使用 MyBatis - 简单映射):**
|
||||
`GET http://localhost:8081/books/mybatis`
|
||||
## 快速开始
|
||||
|
||||
- **按作者搜索 (使用 MyBatis - 复杂 XML 映射):**
|
||||
`GET http://localhost:8081/books/search?author=Orwell`
|
||||
|
||||
### 3. 项目结构
|
||||
|
||||
- **实体类**: `src/main/java/com/example/demo/entity` (Book, Author, Publisher)
|
||||
- **JPA Repository**: `src/main/java/com/example/demo/repository`
|
||||
- **MyBatis Mapper**:
|
||||
- 接口: `src/main/java/com/example/demo/mapper`
|
||||
- XML: `src/main/resources/mapper`
|
||||
- **Service**: `src/main/java/com/example/demo/service`
|
||||
- **Controller**: `src/main/java/com/example/demo/controller`
|
||||
1. **环境**:Java 17+, Maven
|
||||
2. **运行测试**:
|
||||
```bash
|
||||
mvn test -Dtest=MyBatisGeneratorUtilsTest
|
||||
```
|
||||
查看控制台输出,观察自动生成的 SQL 和 XML 结构。
|
||||
|
||||
BIN
my-database.db
BIN
my-database.db
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
package com.example.demo.controller;
|
||||
|
||||
import com.example.demo.entity.Author;
|
||||
import com.example.demo.repository.AuthorRepository;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/authors")
|
||||
public class AuthorController extends BaseController<Author, Long> {
|
||||
|
||||
public AuthorController(AuthorRepository repository) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ import com.example.demo.repository.BaseRepository;
|
||||
import com.example.demo.repository.GenericSpecification;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
@@ -42,6 +42,55 @@ public abstract class BaseController<T, ID extends Serializable> {
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public T get(@PathVariable ID id) {
|
||||
return repository.findById(id).orElseThrow(() -> new RuntimeException("Entity not found"));
|
||||
return repository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity not found"));
|
||||
}
|
||||
|
||||
// --- Write Operations ---
|
||||
|
||||
@PostMapping
|
||||
public T create(@RequestBody T entity) {
|
||||
// JPA Cascade Persist will handle nested objects if configured
|
||||
return repository.save(entity);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public T update(@PathVariable ID id, @RequestBody T entity) {
|
||||
// Ensure ID is set
|
||||
// Note: This is a "save/merge" operation.
|
||||
// If entity contains null fields, they might overwrite DB values depending on how the entity was constructed.
|
||||
// Typically PUT implies full replacement.
|
||||
// For partial update, use PATCH.
|
||||
|
||||
// We can't easily set ID on generic T without reflection or interface,
|
||||
// assuming entity already has ID or we rely on repository.save behavior.
|
||||
// Ideally, we should check if ID exists.
|
||||
if (!repository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity not found");
|
||||
}
|
||||
// In a real generic controller, we would force entity.setId(id).
|
||||
// Here we assume the body contains the correct ID or we trust the user.
|
||||
return repository.save(entity);
|
||||
}
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
public T patch(@PathVariable ID id, @RequestBody Map<String, Object> updates) throws Exception {
|
||||
T dbEntity = repository.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity not found"));
|
||||
|
||||
// Create a copy of ObjectMapper to avoid side effects on the global bean
|
||||
ObjectMapper patchMapper = objectMapper.copy();
|
||||
|
||||
// Configure ObjectMapper to merge nested objects instead of replacing them
|
||||
// This simulates "Deep Merge" for PATCH operations
|
||||
patchMapper.setDefaultMergeable(true);
|
||||
|
||||
// Magic of Jackson: Update existing object with map values
|
||||
patchMapper.readerForUpdating(dbEntity).readValue(patchMapper.writeValueAsString(updates));
|
||||
|
||||
return repository.save(dbEntity);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public void delete(@PathVariable ID id) {
|
||||
repository.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,24 +18,21 @@ public class BookController extends BaseController<Book, Long> {
|
||||
this.bookService = bookService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public Book createBook(@RequestParam String title,
|
||||
@RequestParam String isbn,
|
||||
@RequestParam String author,
|
||||
@RequestParam String publisher) {
|
||||
return bookService.createBook(title, isbn, author, publisher);
|
||||
}
|
||||
// BaseController provides:
|
||||
// POST /books (create)
|
||||
// GET /books (list with dynamic filter)
|
||||
// GET /books/{id} (get one)
|
||||
// PATCH /books/{id} (partial update)
|
||||
// DELETE /books/{id} (delete)
|
||||
|
||||
// Override the base list method to map it to /jpa specifically, or just use the base one
|
||||
// Here we expose the generic list capability at /jpa endpoint to match previous behavior
|
||||
// Keep this for backward compatibility with tests using /jpa endpoint
|
||||
@GetMapping("/jpa")
|
||||
public Object getAllJPA(@RequestParam(required = false) java.util.Map<String, String> allParams) {
|
||||
return super.list(allParams);
|
||||
}
|
||||
|
||||
// Original methods for comparison
|
||||
@GetMapping("/jpa/{id}")
|
||||
public Book getBookById(@PathVariable Long id) {
|
||||
public Book getBookByIdJPA(@PathVariable Long id) {
|
||||
return super.get(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ public class Book {
|
||||
private String title;
|
||||
private String isbn;
|
||||
|
||||
@ManyToOne
|
||||
@ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
|
||||
@JoinColumn(name = "author_id")
|
||||
private Author author;
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@ package com.example.demo.repository;
|
||||
import com.example.demo.entity.Author;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AuthorRepository extends JpaRepository<Author, Long> {
|
||||
public interface AuthorRepository extends BaseRepository<Author, Long> {
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface PeriodicalRepository extends JpaRepository<Periodical, Long> {
|
||||
public interface PeriodicalRepository extends BaseRepository<Periodical, Long> {
|
||||
}
|
||||
|
||||
@@ -3,5 +3,5 @@ package com.example.demo.repository;
|
||||
import com.example.demo.entity.Publisher;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PublisherRepository extends JpaRepository<Publisher, Long> {
|
||||
public interface PublisherRepository extends BaseRepository<Publisher, Long> {
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface RegionRepository extends JpaRepository<Region, Long> {
|
||||
public interface RegionRepository extends BaseRepository<Region, Long> {
|
||||
}
|
||||
|
||||
401
src/main/java/com/example/demo/util/MyBatisGeneratorUtils.java
Normal file
401
src/main/java/com/example/demo/util/MyBatisGeneratorUtils.java
Normal file
@@ -0,0 +1,401 @@
|
||||
package com.example.demo.util;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.JoinColumns;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.OneToOne;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class MyBatisGeneratorUtils {
|
||||
|
||||
private static final String SEPARATOR = "$";
|
||||
|
||||
public static String generateSql(Class<?> rootClass, List<String> fields) {
|
||||
// 1. Parse the fields to build a Join Tree
|
||||
// Use entity name (lowercase) as root alias, e.g., "book"
|
||||
String rootAlias = rootClass.getSimpleName().toLowerCase();
|
||||
JoinNode rootNode = new JoinNode(rootClass, rootAlias);
|
||||
Set<String> selectColumns = new LinkedHashSet<>();
|
||||
|
||||
// 0. Force Select ID for Root (Critical for Collection Mapping)
|
||||
addIdColumn(rootNode, selectColumns, rootAlias);
|
||||
|
||||
for (String fieldPath : fields) {
|
||||
parseFieldPath(rootNode, fieldPath, selectColumns, rootAlias);
|
||||
}
|
||||
|
||||
// 2. Generate SELECT clause
|
||||
StringBuilder sql = new StringBuilder("SELECT \n");
|
||||
sql.append(String.join(", \n", selectColumns));
|
||||
|
||||
// 3. Generate FROM clause
|
||||
String tableName = getTableName(rootClass);
|
||||
sql.append("\nFROM ").append(tableName).append(" ").append(rootNode.alias);
|
||||
|
||||
// 4. Generate JOIN clauses (Recursively)
|
||||
StringBuilder joins = new StringBuilder();
|
||||
generateJoins(rootNode, joins);
|
||||
sql.append(joins);
|
||||
|
||||
return sql.toString();
|
||||
}
|
||||
|
||||
private static void addIdColumn(JoinNode node, Set<String> selectColumns, String rootAlias) {
|
||||
Field idField = getIdField(node.entityClass);
|
||||
if (idField != null) {
|
||||
String columnName = getColumnName(node.entityClass, idField.getName());
|
||||
String columnAlias = getColumnAlias(node.alias, idField.getName(), rootAlias, columnName);
|
||||
selectColumns.add(" " + node.alias + "." + columnName + " AS " + columnAlias);
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseFieldPath(JoinNode currentNode, String fieldPath, Set<String> selectColumns, String rootAlias) {
|
||||
if (!fieldPath.contains(".")) {
|
||||
// Check if this is a relation field (Object or Collection) or a simple field
|
||||
try {
|
||||
Field field = currentNode.entityClass.getDeclaredField(fieldPath);
|
||||
|
||||
// Case 1: Simple Field
|
||||
if (isSimpleField(field)) {
|
||||
String columnName = getColumnName(currentNode.entityClass, fieldPath);
|
||||
String columnAlias = getColumnAlias(currentNode.alias, fieldPath, rootAlias, columnName);
|
||||
selectColumns.add(" " + currentNode.alias + "." + columnName + " AS " + columnAlias);
|
||||
}
|
||||
// Case 2: Relation Field (Object or Collection) - Expand all columns
|
||||
else {
|
||||
// Create child node for this relation
|
||||
Class<?> targetClass = getTargetClass(field); // Use helper
|
||||
|
||||
String relationName = fieldPath;
|
||||
JoinNode childNode = currentNode.children.computeIfAbsent(relationName, k -> {
|
||||
// Alias: book$author
|
||||
String childAlias = currentNode.alias + SEPARATOR + relationName;
|
||||
JoinNode newNode = new JoinNode(targetClass, childAlias, field);
|
||||
// Force Select ID for Child (Critical for Collection Mapping)
|
||||
addIdColumn(newNode, selectColumns, rootAlias);
|
||||
return newNode;
|
||||
});
|
||||
|
||||
// Expand all simple columns of target entity
|
||||
for (Field f : targetClass.getDeclaredFields()) {
|
||||
if (isSimpleField(f)) {
|
||||
String colName = getColumnName(targetClass, f.getName());
|
||||
// Alias: book$author$name
|
||||
String columnAlias = getColumnAlias(childNode.alias, f.getName(), rootAlias, colName);
|
||||
selectColumns.add(" " + childNode.alias + "." + colName + " AS " + columnAlias);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// Ignore or handle error
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Split "author.name" -> "author" and "name"
|
||||
String[] parts = fieldPath.split("\\.", 2);
|
||||
String relationField = parts[0];
|
||||
String remainingPath = parts[1];
|
||||
|
||||
// Find or create child node for this relation
|
||||
JoinNode childNode = currentNode.children.computeIfAbsent(relationField, k -> {
|
||||
Field field = getField(currentNode.entityClass, relationField);
|
||||
Class<?> type = getTargetClass(field); // Use helper
|
||||
|
||||
// Alias: book$author
|
||||
String childAlias = currentNode.alias + SEPARATOR + relationField;
|
||||
JoinNode newNode = new JoinNode(type, childAlias, field);
|
||||
// Force Select ID for Child
|
||||
addIdColumn(newNode, selectColumns, rootAlias);
|
||||
return newNode;
|
||||
});
|
||||
|
||||
// Recursively parse the rest
|
||||
parseFieldPath(childNode, remainingPath, selectColumns, rootAlias);
|
||||
}
|
||||
|
||||
private static String getColumnAlias(String nodeAlias, String fieldName, String rootAlias, String columnName) {
|
||||
// Full alias path should use columnName for the last part to match ResultMap definition
|
||||
// Example: book$author$publish_month (instead of publishMonth)
|
||||
String fullPath = nodeAlias + SEPARATOR + columnName;
|
||||
|
||||
// If node is root (alias "book"), we want "title", not "book$title"
|
||||
// If node is child ("book$author"), we want "author$name", not "book$author$name"
|
||||
|
||||
if (fullPath.startsWith(rootAlias + SEPARATOR)) {
|
||||
return fullPath.substring(rootAlias.length() + SEPARATOR.length());
|
||||
}
|
||||
return fullPath; // Should not happen if rootAlias is correct
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
private static String buildJoinCondition(JoinNode parent, JoinNode child) {
|
||||
Field relationField = child.relationFieldFromParent;
|
||||
|
||||
// Handle @OneToMany(mappedBy="...")
|
||||
if (relationField.isAnnotationPresent(jakarta.persistence.OneToMany.class)) {
|
||||
jakarta.persistence.OneToMany oneToMany = relationField.getAnnotation(jakarta.persistence.OneToMany.class);
|
||||
String mappedBy = oneToMany.mappedBy();
|
||||
if (!mappedBy.isEmpty()) {
|
||||
// Parent: Author (id), Child: Book (author_id)
|
||||
// mappedBy = "author" -> Child has field "author"
|
||||
// Condition: parent.id = child.author_id
|
||||
|
||||
// We need to find the column name for "author" in Child entity
|
||||
// Convention: mappedBy + "_id"
|
||||
String childJoinColumn = mappedBy + "_id";
|
||||
|
||||
// Try to find @JoinColumn on the child side field
|
||||
try {
|
||||
Field childField = child.entityClass.getDeclaredField(mappedBy);
|
||||
if (childField.isAnnotationPresent(JoinColumn.class)) {
|
||||
childJoinColumn = childField.getAnnotation(JoinColumn.class).name();
|
||||
}
|
||||
} catch (NoSuchFieldException e) {
|
||||
// ignore, use convention
|
||||
}
|
||||
|
||||
return parent.alias + ".id = " + child.alias + "." + childJoinColumn;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle @JoinColumn (ManyToOne / OneToOne)
|
||||
if (relationField.isAnnotationPresent(JoinColumn.class)) {
|
||||
// ... existing logic ...
|
||||
JoinColumn joinColumn = relationField.getAnnotation(JoinColumn.class);
|
||||
String foreignKey = joinColumn.name();
|
||||
String referencedColumn = joinColumn.referencedColumnName();
|
||||
if (referencedColumn.isEmpty()) referencedColumn = "id";
|
||||
|
||||
return parent.alias + "." + foreignKey + " = " + child.alias + "." + referencedColumn;
|
||||
}
|
||||
|
||||
// Handle @JoinColumns (Composite Key)
|
||||
if (relationField.isAnnotationPresent(JoinColumns.class)) {
|
||||
// ... existing logic ...
|
||||
JoinColumns joinColumns = relationField.getAnnotation(JoinColumns.class);
|
||||
return Arrays.stream(joinColumns.value())
|
||||
.map(jc -> {
|
||||
String refCol = jc.referencedColumnName();
|
||||
if (refCol.isEmpty()) refCol = "id";
|
||||
return parent.alias + "." + jc.name() + " = " + child.alias + "." + refCol;
|
||||
})
|
||||
.collect(Collectors.joining(" AND "));
|
||||
}
|
||||
|
||||
// Fallback (Convention: fieldName_id)
|
||||
return parent.alias + "." + relationField.getName() + "_id = " + child.alias + ".id";
|
||||
}
|
||||
|
||||
// --- XML Generation ---
|
||||
|
||||
public static String generateMapperXml(Class<?> rootClass, String namespace, List<String> fields) {
|
||||
StringBuilder xml = new StringBuilder();
|
||||
xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n");
|
||||
xml.append("<!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\" \"http://mybatis.org/dtd/mybatis-3-mapper.dtd\">\n");
|
||||
xml.append("<mapper namespace=\"").append(namespace).append("\">\n");
|
||||
|
||||
// 1. Identify all involved classes
|
||||
Set<Class<?>> involvedClasses = new LinkedHashSet<>();
|
||||
collectInvolvedClasses(rootClass, fields, involvedClasses);
|
||||
|
||||
// 2. Generate ResultMap for each class
|
||||
for (Class<?> clazz : involvedClasses) {
|
||||
xml.append(generateResultMap(clazz, involvedClasses)).append("\n");
|
||||
}
|
||||
|
||||
// 3. Generate Select Statement
|
||||
xml.append(" <select id=\"selectCustom\" resultMap=\"").append(rootClass.getSimpleName()).append("Map\">\n");
|
||||
String sql = generateSql(rootClass, fields);
|
||||
// Indent SQL
|
||||
xml.append(indent(sql, 8)).append("\n");
|
||||
xml.append(" </select>\n");
|
||||
|
||||
xml.append("</mapper>");
|
||||
return xml.toString();
|
||||
}
|
||||
|
||||
private static void collectInvolvedClasses(Class<?> rootClass, List<String> fields, Set<Class<?>> involvedClasses) {
|
||||
involvedClasses.add(rootClass);
|
||||
JoinNode rootNode = new JoinNode(rootClass, "root"); // alias doesn't matter here
|
||||
for (String fieldPath : fields) {
|
||||
parseFieldPathForClasses(rootNode, fieldPath, involvedClasses);
|
||||
}
|
||||
}
|
||||
|
||||
private static void parseFieldPathForClasses(JoinNode currentNode, String fieldPath, Set<Class<?>> involvedClasses) {
|
||||
if (!fieldPath.contains(".")) {
|
||||
try {
|
||||
Field field = currentNode.entityClass.getDeclaredField(fieldPath);
|
||||
if (!isSimpleField(field)) {
|
||||
Class<?> targetClass = getTargetClass(field);
|
||||
involvedClasses.add(targetClass);
|
||||
}
|
||||
} catch (NoSuchFieldException e) { }
|
||||
return;
|
||||
}
|
||||
|
||||
String[] parts = fieldPath.split("\\.", 2);
|
||||
String relationField = parts[0];
|
||||
String remainingPath = parts[1];
|
||||
|
||||
try {
|
||||
Field field = getField(currentNode.entityClass, relationField);
|
||||
Class<?> targetClass = getTargetClass(field);
|
||||
involvedClasses.add(targetClass);
|
||||
|
||||
JoinNode childNode = currentNode.children.computeIfAbsent(relationField, k -> new JoinNode(targetClass, "child"));
|
||||
parseFieldPathForClasses(childNode, remainingPath, involvedClasses);
|
||||
} catch (Exception e) {}
|
||||
}
|
||||
|
||||
private static Class<?> getTargetClass(Field field) {
|
||||
Class<?> type = field.getType();
|
||||
if (Collection.class.isAssignableFrom(type)) {
|
||||
java.lang.reflect.ParameterizedType pt = (java.lang.reflect.ParameterizedType) field.getGenericType();
|
||||
return (Class<?>) pt.getActualTypeArguments()[0];
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private static String generateResultMap(Class<?> clazz, Set<Class<?>> involvedClasses) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String mapId = clazz.getSimpleName() + "Map";
|
||||
sb.append(" <resultMap id=\"").append(mapId).append("\" type=\"").append(clazz.getName()).append("\">\n");
|
||||
|
||||
// ID Field
|
||||
Field idField = getIdField(clazz);
|
||||
if (idField != null) {
|
||||
String colName = getColumnName(clazz, idField.getName());
|
||||
sb.append(" <id property=\"").append(idField.getName()).append("\" column=\"").append(colName).append("\"/>\n");
|
||||
}
|
||||
|
||||
// Other Fields
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
if (field.equals(idField)) continue;
|
||||
|
||||
if (isSimpleField(field)) {
|
||||
String colName = getColumnName(clazz, field.getName());
|
||||
sb.append(" <result property=\"").append(field.getName()).append("\" column=\"").append(colName).append("\"/>\n");
|
||||
} else {
|
||||
// Association or Collection
|
||||
Class<?> targetClass = getTargetClass(field);
|
||||
// Only generate if target class is in our involved list (or we could generate all, but that risks infinite recursion if graph is large)
|
||||
// For "Complete XML", let's include it if it's in the involved list to ensure we have the maps.
|
||||
if (involvedClasses.contains(targetClass)) {
|
||||
String targetMapId = targetClass.getSimpleName() + "Map";
|
||||
String prefix = field.getName() + SEPARATOR;
|
||||
|
||||
if (Collection.class.isAssignableFrom(field.getType())) {
|
||||
sb.append(" <collection property=\"").append(field.getName())
|
||||
.append("\" resultMap=\"").append(targetMapId)
|
||||
.append("\" columnPrefix=\"").append(prefix).append("\"/>\n");
|
||||
} else {
|
||||
sb.append(" <association property=\"").append(field.getName())
|
||||
.append("\" resultMap=\"").append(targetMapId)
|
||||
.append("\" columnPrefix=\"").append(prefix).append("\"/>\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append(" </resultMap>");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static Field getIdField(Class<?> clazz) {
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
if (field.isAnnotationPresent(jakarta.persistence.Id.class)) {
|
||||
return field;
|
||||
}
|
||||
}
|
||||
// Fallback: look for "id"
|
||||
try {
|
||||
return clazz.getDeclaredField("id");
|
||||
} catch (NoSuchFieldException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String indent(String text, int spaces) {
|
||||
String indent = new String(new char[spaces]).replace("\0", " ");
|
||||
return text.replaceAll("(?m)^", indent); // Multiline regex
|
||||
}
|
||||
|
||||
// --- Helper Classes & Methods ---
|
||||
|
||||
private static void generateJoins(JoinNode node, StringBuilder sb) {
|
||||
for (Map.Entry<String, JoinNode> entry : node.children.entrySet()) {
|
||||
JoinNode child = entry.getValue();
|
||||
|
||||
// Determine Join Condition
|
||||
// Assumption: @JoinColumn is on the parent entity's field (ManyToOne/OneToOne)
|
||||
// parent.author_id = child.id
|
||||
String joinCondition = buildJoinCondition(node, child);
|
||||
String tableName = getTableName(child.entityClass);
|
||||
|
||||
sb.append("\nLEFT JOIN ").append(tableName).append(" ").append(child.alias)
|
||||
.append(" ON ").append(joinCondition);
|
||||
|
||||
// Recursively generate joins for children
|
||||
generateJoins(child, sb);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSimpleField(Field field) {
|
||||
// Simple check: String, Number, Date, etc. or Primitive
|
||||
Class<?> type = field.getType();
|
||||
return type.isPrimitive() ||
|
||||
String.class.isAssignableFrom(type) ||
|
||||
Number.class.isAssignableFrom(type) ||
|
||||
Boolean.class.isAssignableFrom(type) ||
|
||||
java.util.Date.class.isAssignableFrom(type);
|
||||
}
|
||||
|
||||
private static class JoinNode {
|
||||
Class<?> entityClass;
|
||||
String alias;
|
||||
Field relationFieldFromParent; // The field in parent that points to this entity
|
||||
Map<String, JoinNode> children = new LinkedHashMap<>();
|
||||
|
||||
public JoinNode(Class<?> entityClass, String alias) {
|
||||
this.entityClass = entityClass;
|
||||
this.alias = alias;
|
||||
}
|
||||
|
||||
public JoinNode(Class<?> entityClass, String alias, Field relationFieldFromParent) {
|
||||
this(entityClass, alias);
|
||||
this.relationFieldFromParent = relationFieldFromParent;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getTableName(Class<?> clazz) {
|
||||
if (clazz.isAnnotationPresent(Table.class)) {
|
||||
String name = clazz.getAnnotation(Table.class).name();
|
||||
if (!name.isEmpty()) return name;
|
||||
}
|
||||
return clazz.getSimpleName().toLowerCase();
|
||||
}
|
||||
|
||||
private static String getColumnName(Class<?> clazz, String fieldName) {
|
||||
Field field = getField(clazz, fieldName);
|
||||
if (field.isAnnotationPresent(Column.class)) {
|
||||
String name = field.getAnnotation(Column.class).name();
|
||||
if (!name.isEmpty()) return name;
|
||||
}
|
||||
return fieldName; // Default: field name is column name (simplified)
|
||||
}
|
||||
|
||||
private static Field getField(Class<?> clazz, String fieldName) {
|
||||
try {
|
||||
return clazz.getDeclaredField(fieldName);
|
||||
} catch (NoSuchFieldException e) {
|
||||
throw new RuntimeException("Field not found: " + clazz.getName() + "." + fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,36 +17,106 @@ public class BookControllerTest extends BaseTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Test
|
||||
void testCreateAndGetBook() throws Exception {
|
||||
mockMvc.perform(post("/books")
|
||||
.param("title", "Integration Test Book")
|
||||
.param("isbn", "999-888")
|
||||
.param("author", "Integration Author")
|
||||
.param("publisher", "Integration Pub"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Integration Test Book"));
|
||||
@Autowired
|
||||
private jakarta.persistence.EntityManager em;
|
||||
|
||||
mockMvc.perform(get("/books/jpa"))
|
||||
@Test
|
||||
void testCascadeWriteOperations() throws Exception {
|
||||
// 1. Create Book with Nested Author (Cascade Persist)
|
||||
// POST /books (JSON)
|
||||
String createJson = """
|
||||
{
|
||||
"title": "Cascade Book",
|
||||
"isbn": "CAS-001",
|
||||
"publishMonth": "2024-01",
|
||||
"author": {
|
||||
"name": "Cascade Author",
|
||||
"region": {
|
||||
"name": "Cascade Region"
|
||||
}
|
||||
},
|
||||
"publisher": {
|
||||
"name": "Cascade Publisher"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
String response = mockMvc.perform(post("/books")
|
||||
.contentType("application/json")
|
||||
.content(createJson))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[?(@.title == 'Integration Test Book')]").exists());
|
||||
.andExpect(jsonPath("$.id").exists())
|
||||
.andExpect(jsonPath("$.title").value("Cascade Book"))
|
||||
.andExpect(jsonPath("$.author.name").value("Cascade Author"))
|
||||
.andExpect(jsonPath("$.author.region.name").value("Cascade Region"))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
Integer bookId = com.jayway.jsonpath.JsonPath.read(response, "$.id");
|
||||
Integer authorId = com.jayway.jsonpath.JsonPath.read(response, "$.author.id");
|
||||
|
||||
// 2. Patch Update (Partial Update)
|
||||
// Update title and author name
|
||||
// PATCH /books/{id}
|
||||
String patchJson = """
|
||||
{
|
||||
"title": "Patched Title",
|
||||
"author": {
|
||||
"id": %d,
|
||||
"name": "Patched Author"
|
||||
}
|
||||
}
|
||||
""".formatted(authorId);
|
||||
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch("/books/" + bookId)
|
||||
.contentType("application/json")
|
||||
.content(patchJson))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").value("Patched Title"))
|
||||
.andExpect(jsonPath("$.author.name").value("Patched Author"))
|
||||
.andExpect(jsonPath("$.author.region.name").value("Cascade Region")); // Region should remain untouched
|
||||
|
||||
// Clear Persistence Context to ensure DELETE loads fresh state (and sees the relationship for cascading)
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
// 3. Delete (Cascade Remove)
|
||||
// If we delete Author, books should be deleted (because Author.books is CascadeType.ALL)
|
||||
// Let's test deleting the author directly
|
||||
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/authors/" + authorId))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Verify Book is gone
|
||||
mockMvc.perform(get("/books/" + bookId))
|
||||
.andExpect(status().is4xxClientError()); // Should be 404 or 500 depending on handling
|
||||
}
|
||||
|
||||
@Test
|
||||
void testDynamicSearchAndFilter() throws Exception {
|
||||
// 1. Create two books with different authors
|
||||
String book1 = """
|
||||
{
|
||||
"title": "Java Guide",
|
||||
"isbn": "JG-001",
|
||||
"author": { "name": "John Doe" },
|
||||
"publisher": { "name": "Tech Pub" }
|
||||
}
|
||||
""";
|
||||
mockMvc.perform(post("/books")
|
||||
.param("title", "Java Guide")
|
||||
.param("isbn", "JG-001")
|
||||
.param("author", "John Doe")
|
||||
.param("publisher", "Tech Pub"))
|
||||
.contentType("application/json")
|
||||
.content(book1))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
String book2 = """
|
||||
{
|
||||
"title": "Python Guide",
|
||||
"isbn": "PG-001",
|
||||
"author": { "name": "Jane Smith" },
|
||||
"publisher": { "name": "Tech Pub" }
|
||||
}
|
||||
""";
|
||||
mockMvc.perform(post("/books")
|
||||
.param("title", "Python Guide")
|
||||
.param("isbn", "PG-001")
|
||||
.param("author", "Jane Smith")
|
||||
.param("publisher", "Tech Pub"))
|
||||
.contentType("application/json")
|
||||
.content(book2))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// 2. Search by author name (partial match with wildcard) and request specific fields
|
||||
@@ -79,11 +149,22 @@ public class BookControllerTest extends BaseTest {
|
||||
@Test
|
||||
void testSquigglyFilter() throws Exception {
|
||||
// 1. Create a book to get an ID
|
||||
String book = """
|
||||
{
|
||||
"title": "Squiggly Test Book",
|
||||
"isbn": "SQ-123",
|
||||
"author": { "name": "Squiggly Author" },
|
||||
"publisher": { "name": "Squiggly Pub" },
|
||||
"publishMonth": "2023-01",
|
||||
"periodical": {
|
||||
"name": "Monthly Journal Squiggly Pub",
|
||||
"publishMonth": "2023-01"
|
||||
}
|
||||
}
|
||||
""";
|
||||
String createResponse = mockMvc.perform(post("/books")
|
||||
.param("title", "Squiggly Test Book")
|
||||
.param("isbn", "SQ-123")
|
||||
.param("author", "Squiggly Author")
|
||||
.param("publisher", "Squiggly Pub"))
|
||||
.contentType("application/json")
|
||||
.content(book))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
@@ -106,18 +187,37 @@ public class BookControllerTest extends BaseTest {
|
||||
.andExpect(jsonPath("$.author").doesNotExist());
|
||||
|
||||
// 4. Verify deep filtering with Region
|
||||
String deepResponse = mockMvc.perform(get("/books/jpa/" + id)
|
||||
// Note: Squiggly Author has no region explicitly set, so region might be null or default
|
||||
// Let's create a book WITH region to be sure
|
||||
String bookWithRegion = """
|
||||
{
|
||||
"title": "Deep Filter Book",
|
||||
"isbn": "DF-001",
|
||||
"author": {
|
||||
"name": "Deep Author",
|
||||
"region": { "name": "Deep Region" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
String deepCreateResponse = mockMvc.perform(post("/books")
|
||||
.contentType("application/json")
|
||||
.content(bookWithRegion))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
Integer deepId = com.jayway.jsonpath.JsonPath.read(deepCreateResponse, "$.id");
|
||||
|
||||
String deepResponse = mockMvc.perform(get("/books/jpa/" + deepId)
|
||||
.param("fields", "title,author.name,author.region.name"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").exists())
|
||||
.andExpect(jsonPath("$.author.name").exists())
|
||||
.andExpect(jsonPath("$.author.region.name").value("Default Region"))
|
||||
.andExpect(jsonPath("$.author.region.name").value("Deep Region"))
|
||||
.andExpect(jsonPath("$.author.region.id").doesNotExist())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
// 5. Verify non-ID association with Periodical
|
||||
// 5. Verify non-ID association with Periodical (using the first book which had periodical)
|
||||
String periodicalResponse = mockMvc.perform(get("/books/jpa/" + id)
|
||||
.param("fields", "title,publishMonth,periodical.name,periodical.publishMonth"))
|
||||
.param("fields", "title,publishMonth,periodical"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.title").exists())
|
||||
.andExpect(jsonPath("$.publishMonth").value("2023-01"))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.example.demo.util;
|
||||
|
||||
import com.example.demo.entity.Author;
|
||||
import com.example.demo.entity.Book;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class MyBatisGeneratorUtilsTest {
|
||||
|
||||
@Test
|
||||
void testGenerateSql_Simple() {
|
||||
List<String> fields = Arrays.asList("title", "isbn");
|
||||
String sql = MyBatisGeneratorUtils.generateSql(Book.class, fields);
|
||||
|
||||
System.out.println("=== Simple SQL ===");
|
||||
System.out.println(sql);
|
||||
|
||||
assertTrue(sql.contains("SELECT"));
|
||||
// Root fields should NOT have prefix (to match standard ResultMap)
|
||||
// book.title AS title
|
||||
assertTrue(sql.contains("book.title AS title"));
|
||||
assertTrue(sql.contains("FROM book book"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSql_OneToMany_List() {
|
||||
// Author -> List<Book>
|
||||
// Request: author.name, author.books.title
|
||||
List<String> fields = Arrays.asList("name", "books.title");
|
||||
String sql = MyBatisGeneratorUtils.generateSql(Author.class, fields);
|
||||
|
||||
System.out.println("=== OneToMany List SQL ===");
|
||||
System.out.println(sql);
|
||||
|
||||
// Root: author
|
||||
assertTrue(sql.contains("FROM author author"));
|
||||
assertTrue(sql.contains("author.name AS name"));
|
||||
// Check Forced ID
|
||||
assertTrue(sql.contains("author.id AS id"));
|
||||
|
||||
// Join: author -> book (OneToMany)
|
||||
// mappedBy="author" in Author means Book has "author" field.
|
||||
// Join condition should be: author.id = books.author_id
|
||||
assertTrue(sql.contains("LEFT JOIN book author$books"));
|
||||
|
||||
// Correct Join Condition for OneToMany
|
||||
// author.id = author$books.author_id (assuming default convention or @JoinColumn on Book.author)
|
||||
// Book.java: @JoinColumn(name = "author_id") private Author author;
|
||||
assertTrue(sql.contains("author.id = author$books.author_id"));
|
||||
|
||||
// Check Child Columns
|
||||
assertTrue(sql.contains("author$books.title AS books$title"));
|
||||
// Check Child Forced ID
|
||||
assertTrue(sql.contains("author$books.id AS books$id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateMapperXml() {
|
||||
List<String> fields = Arrays.asList("title", "author.name", "author.books");
|
||||
String xml = MyBatisGeneratorUtils.generateMapperXml(Book.class, "com.example.demo.mapper.BookMapper", fields);
|
||||
|
||||
System.out.println("=== Generated Mapper XML ===");
|
||||
System.out.println(xml);
|
||||
|
||||
assertTrue(xml.contains("<mapper namespace=\"com.example.demo.mapper.BookMapper\">"));
|
||||
assertTrue(xml.contains("<resultMap id=\"BookMap\""));
|
||||
assertTrue(xml.contains("<resultMap id=\"AuthorMap\""));
|
||||
|
||||
// Verify BookMap -> AuthorMap association
|
||||
assertTrue(xml.contains("<association property=\"author\" resultMap=\"AuthorMap\" columnPrefix=\"author$\"/>"));
|
||||
|
||||
// Verify AuthorMap -> Books collection
|
||||
assertTrue(xml.contains("<collection property=\"books\" resultMap=\"BookMap\" columnPrefix=\"books$\"/>"));
|
||||
|
||||
// Verify SQL is included
|
||||
assertTrue(xml.contains("SELECT"));
|
||||
// Use column name in alias (publish_month)
|
||||
assertTrue(xml.contains("book$author$books.publish_month AS author$books$publish_month"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSql_NestedJoin() {
|
||||
List<String> fields = Arrays.asList("title", "author.name", "author.region.name");
|
||||
String sql = MyBatisGeneratorUtils.generateSql(Book.class, fields);
|
||||
|
||||
System.out.println("=== Nested Join SQL ===");
|
||||
System.out.println(sql);
|
||||
|
||||
// Verify Joins
|
||||
assertTrue(sql.contains("LEFT JOIN author book$author"));
|
||||
assertTrue(sql.contains("LEFT JOIN region book$author$region"));
|
||||
|
||||
// Verify Columns
|
||||
// Child fields SHOULD have relative prefix
|
||||
// book$author.name AS author$name
|
||||
assertTrue(sql.contains("book$author.name AS author$name"));
|
||||
// book$author$region.name AS author$region$name
|
||||
assertTrue(sql.contains("book$author$region.name AS author$region$name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateSql_ExpandObject() {
|
||||
// "periodical" is an object, should expand to periodical.name, periodical.publishMonth etc.
|
||||
List<String> fields = Arrays.asList("title", "periodical");
|
||||
String sql = MyBatisGeneratorUtils.generateSql(Book.class, fields);
|
||||
|
||||
System.out.println("=== Expand Object SQL ===");
|
||||
System.out.println(sql);
|
||||
|
||||
assertTrue(sql.contains("LEFT JOIN periodical book$periodical"));
|
||||
assertTrue(sql.contains("book$periodical.name AS periodical$name"));
|
||||
// Use column name in alias: publish_month
|
||||
assertTrue(sql.contains("book$periodical.publish_month AS periodical$publish_month"));
|
||||
}
|
||||
@Test
|
||||
void testGenerateSql_CompositeKeyJoin() {
|
||||
List<String> fields = Arrays.asList("title", "periodical.name");
|
||||
String sql = MyBatisGeneratorUtils.generateSql(Book.class, fields);
|
||||
|
||||
System.out.println("=== Composite Key Join SQL ===");
|
||||
System.out.println(sql);
|
||||
|
||||
// Verify Composite Join Condition (publisher_id AND publish_month)
|
||||
assertTrue(sql.contains("LEFT JOIN periodical book$periodical"));
|
||||
assertTrue(sql.contains("book.publisher_id = book$periodical.publisher_id"));
|
||||
assertTrue(sql.contains("book.publish_month = book$periodical.publish_month"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user