This commit is contained in:
2026-01-23 17:41:45 +08:00
parent a146fd68a9
commit 13c45dcedb
15 changed files with 897 additions and 76 deletions

7
1.sql Normal file
View 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
View 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
View File

@@ -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 结构。

Binary file not shown.

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View 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);
}
}
}

View File

@@ -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"))

View File

@@ -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"));
}
}