diff --git a/1.sql b/1.sql new file mode 100644 index 0000000..dbe2eb7 --- /dev/null +++ b/1.sql @@ -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 \ No newline at end of file diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md new file mode 100644 index 0000000..e731bfb --- /dev/null +++ b/AI_CONTEXT.md @@ -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`: Extends `JpaRepository` & `JpaSpecificationExecutor`. + * `BaseController`: 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 `` 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 `` 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. diff --git a/README.md b/README.md index b41d518..8299d0e 100644 --- a/README.md +++ b/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 {} + +// 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 + + + + +``` -项目启动时会自动插入一些测试数据。你可以使用以下接口进行测试: +### 如何使用 +目前引擎逻辑位于 `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 结构。 diff --git a/my-database.db b/my-database.db index 68f5c1d..bcbb436 100644 Binary files a/my-database.db and b/my-database.db differ diff --git a/src/main/java/com/example/demo/controller/AuthorController.java b/src/main/java/com/example/demo/controller/AuthorController.java new file mode 100644 index 0000000..fb88535 --- /dev/null +++ b/src/main/java/com/example/demo/controller/AuthorController.java @@ -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 { + + public AuthorController(AuthorRepository repository) { + super(repository); + } +} diff --git a/src/main/java/com/example/demo/controller/BaseController.java b/src/main/java/com/example/demo/controller/BaseController.java index 9076d14..faf1372 100644 --- a/src/main/java/com/example/demo/controller/BaseController.java +++ b/src/main/java/com/example/demo/controller/BaseController.java @@ -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 { @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 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); } } diff --git a/src/main/java/com/example/demo/controller/BookController.java b/src/main/java/com/example/demo/controller/BookController.java index e9cca5a..ede6f2b 100644 --- a/src/main/java/com/example/demo/controller/BookController.java +++ b/src/main/java/com/example/demo/controller/BookController.java @@ -18,24 +18,21 @@ public class BookController extends BaseController { 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 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); } diff --git a/src/main/java/com/example/demo/entity/Book.java b/src/main/java/com/example/demo/entity/Book.java index e533685..0f65b4e 100644 --- a/src/main/java/com/example/demo/entity/Book.java +++ b/src/main/java/com/example/demo/entity/Book.java @@ -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; diff --git a/src/main/java/com/example/demo/repository/AuthorRepository.java b/src/main/java/com/example/demo/repository/AuthorRepository.java index 505c2ea..a89a8fa 100644 --- a/src/main/java/com/example/demo/repository/AuthorRepository.java +++ b/src/main/java/com/example/demo/repository/AuthorRepository.java @@ -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 { +public interface AuthorRepository extends BaseRepository { } diff --git a/src/main/java/com/example/demo/repository/PeriodicalRepository.java b/src/main/java/com/example/demo/repository/PeriodicalRepository.java index afcb75d..9187305 100644 --- a/src/main/java/com/example/demo/repository/PeriodicalRepository.java +++ b/src/main/java/com/example/demo/repository/PeriodicalRepository.java @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface PeriodicalRepository extends JpaRepository { +public interface PeriodicalRepository extends BaseRepository { } diff --git a/src/main/java/com/example/demo/repository/PublisherRepository.java b/src/main/java/com/example/demo/repository/PublisherRepository.java index db5238a..6ae2e05 100644 --- a/src/main/java/com/example/demo/repository/PublisherRepository.java +++ b/src/main/java/com/example/demo/repository/PublisherRepository.java @@ -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 { +public interface PublisherRepository extends BaseRepository { } diff --git a/src/main/java/com/example/demo/repository/RegionRepository.java b/src/main/java/com/example/demo/repository/RegionRepository.java index 4857aee..32a89a9 100644 --- a/src/main/java/com/example/demo/repository/RegionRepository.java +++ b/src/main/java/com/example/demo/repository/RegionRepository.java @@ -5,5 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface RegionRepository extends JpaRepository { +public interface RegionRepository extends BaseRepository { } diff --git a/src/main/java/com/example/demo/util/MyBatisGeneratorUtils.java b/src/main/java/com/example/demo/util/MyBatisGeneratorUtils.java new file mode 100644 index 0000000..442312f --- /dev/null +++ b/src/main/java/com/example/demo/util/MyBatisGeneratorUtils.java @@ -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 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 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 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 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 fields) { + StringBuilder xml = new StringBuilder(); + xml.append("\n"); + xml.append("\n"); + xml.append("\n"); + + // 1. Identify all involved classes + Set> 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(" \n"); + + xml.append(""); + return xml.toString(); + } + + private static void collectInvolvedClasses(Class rootClass, List fields, Set> 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> 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> involvedClasses) { + StringBuilder sb = new StringBuilder(); + String mapId = clazz.getSimpleName() + "Map"; + sb.append(" \n"); + + // ID Field + Field idField = getIdField(clazz); + if (idField != null) { + String colName = getColumnName(clazz, idField.getName()); + sb.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(" \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(" \n"); + } else { + sb.append(" \n"); + } + } + } + } + sb.append(" "); + 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 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 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); + } + } +} diff --git a/src/test/java/com/example/demo/controller/BookControllerTest.java b/src/test/java/com/example/demo/controller/BookControllerTest.java index 95b7041..9341482 100644 --- a/src/test/java/com/example/demo/controller/BookControllerTest.java +++ b/src/test/java/com/example/demo/controller/BookControllerTest.java @@ -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")) diff --git a/src/test/java/com/example/demo/util/MyBatisGeneratorUtilsTest.java b/src/test/java/com/example/demo/util/MyBatisGeneratorUtilsTest.java new file mode 100644 index 0000000..985dbe2 --- /dev/null +++ b/src/test/java/com/example/demo/util/MyBatisGeneratorUtilsTest.java @@ -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 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 + // Request: author.name, author.books.title + List 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 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("")); + assertTrue(xml.contains(" AuthorMap association + assertTrue(xml.contains("")); + + // Verify AuthorMap -> Books collection + assertTrue(xml.contains("")); + + // 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 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 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 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")); + } +}