diff --git a/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/DefaultShellCallback.java b/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/DefaultShellCallback.java index 495d9ac73..8f17eeb27 100644 --- a/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/DefaultShellCallback.java +++ b/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/DefaultShellCallback.java @@ -73,4 +73,15 @@ public File getDirectory(String targetProject, String targetPackage) throws Shel public boolean isOverwriteEnabled() { return overwrite; } + + @Override + public boolean isMergeSupported() { + return true; + } + + @Override + public String mergeJavaFile(String newFileSource, File existingFile, + String[] javadocTags, String fileEncoding) throws ShellException { + return JavaFileMerger.getMergedSource(newFileSource, existingFile, javadocTags, fileEncoding); + } } diff --git a/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/JavaFileMerger.java b/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/JavaFileMerger.java new file mode 100644 index 000000000..0651c409b --- /dev/null +++ b/core/mybatis-generator-core/src/main/java/org/mybatis/generator/internal/JavaFileMerger.java @@ -0,0 +1,247 @@ +/* + * Copyright 2006-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mybatis.generator.internal; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParseResult; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.ImportDeclaration; +import com.github.javaparser.ast.body.BodyDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.expr.AnnotationExpr; +import org.mybatis.generator.exception.ShellException; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.mybatis.generator.internal.util.messages.Messages.getString; + +/** + * This class handles the task of merging changes into an existing Java file using JavaParser. + * It supports merging by removing methods and fields that have specific JavaDoc tags or annotations. + * + * @author Freeman + */ +public class JavaFileMerger { + + private JavaFileMerger() { + } + + /** + * Merge a newly generated Java file with an existing Java file. + * + * @param newFileSource the source of the newly generated Java file + * @param existingFile the existing Java file + * @param javadocTags the JavaDoc tags that denote which methods and fields in the old file to delete + * @param fileEncoding the file encoding for reading existing Java files + * @return the merged source, properly formatted + * @throws ShellException if the file cannot be merged for some reason + */ + public static String getMergedSource(String newFileSource, File existingFile, + String[] javadocTags, String fileEncoding) throws ShellException { + try { + String existingFileContent = readFileContent(existingFile, fileEncoding); + return getMergedSource(newFileSource, existingFileContent, javadocTags); + } catch (IOException e) { + throw new ShellException(getString("Warning.13", existingFile.getName()), e); + } + } + + /** + * Merge a newly generated Java file with existing Java file content. + * + * @param newFileSource the source of the newly generated Java file + * @param existingFileContent the content of the existing Java file + * @param javadocTags the JavaDoc tags that denote which methods and fields in the old file to delete + * @return the merged source, properly formatted + * @throws ShellException if the file cannot be merged for some reason + */ + public static String getMergedSource(String newFileSource, String existingFileContent, + String[] javadocTags) throws ShellException { + try { + JavaParser javaParser = new JavaParser(); + + // Parse the new file + ParseResult newParseResult = javaParser.parse(newFileSource); + if (!newParseResult.isSuccessful()) { + throw new ShellException("Failed to parse new Java file: " + newParseResult.getProblems()); + } + CompilationUnit newCompilationUnit = newParseResult.getResult().orElseThrow(); + + // Parse the existing file + ParseResult existingParseResult = javaParser.parse(existingFileContent); + if (!existingParseResult.isSuccessful()) { + throw new ShellException("Failed to parse existing Java file: " + existingParseResult.getProblems()); + } + CompilationUnit existingCompilationUnit = existingParseResult.getResult().orElseThrow(); + + // Perform the merge + CompilationUnit mergedCompilationUnit = performMerge(newCompilationUnit, existingCompilationUnit, javadocTags); + + return mergedCompilationUnit.toString(); + } catch (Exception e) { + throw new ShellException("Error merging Java files: " + e.getMessage(), e); + } + } + + private static CompilationUnit performMerge(CompilationUnit newCompilationUnit, + CompilationUnit existingCompilationUnit, + String[] javadocTags) { + // Start with the new compilation unit as the base (to get new generated elements first) + CompilationUnit mergedCompilationUnit = newCompilationUnit.clone(); + + // Merge imports + mergeImports(existingCompilationUnit, mergedCompilationUnit); + + // Add preserved (non-generated) elements from existing file at the end + addPreservedElements(existingCompilationUnit, mergedCompilationUnit, javadocTags); + + return mergedCompilationUnit; + } + + private static boolean isGeneratedElement(BodyDeclaration member, String[] javadocTags) { + return hasGeneratedAnnotation(member) || hasGeneratedJavadocTag(member, javadocTags); + } + + private static boolean hasGeneratedAnnotation(BodyDeclaration member) { + for (AnnotationExpr annotation : member.getAnnotations()) { + String annotationName = annotation.getNameAsString(); + // Check for @Generated annotation (both javax and jakarta packages) + if ("Generated".equals(annotationName) || + "javax.annotation.Generated".equals(annotationName) || + "jakarta.annotation.Generated".equals(annotationName)) { + return true; + } + } + return false; + } + + private static boolean hasGeneratedJavadocTag(BodyDeclaration member, String[] javadocTags) { + // Check if the member has a comment and if it contains any of the javadoc tags + if (member.getComment().isPresent()) { + String commentContent = member.getComment().orElseThrow().getContent(); + for (String tag : javadocTags) { + if (commentContent.contains(tag)) { + return true; + } + } + } + return false; + } + + private static void mergeImports(CompilationUnit existingCompilationUnit, + CompilationUnit mergedCompilationUnit) { + record ImportInfo(String name, boolean isStatic, boolean isAsterisk) implements Comparable { + @Override + public int compareTo(ImportInfo other) { + // Static imports come last + if (this.isStatic != other.isStatic) { + return this.isStatic ? 1 : -1; + } + + // Within the same category (static or non-static), sort by import order priority + int priorityThis = getImportPriority(this.name); + int priorityOther = getImportPriority(other.name); + + if (priorityThis != priorityOther) { + return Integer.compare(priorityThis, priorityOther); + } + + // Within the same priority, use natural ordering (case-insensitive) + return String.CASE_INSENSITIVE_ORDER.compare(this.name, other.name); + } + } + + // Collect all imports from both compilation units + Set allImports = new LinkedHashSet<>(); + + // Add imports from new file + for (ImportDeclaration importDecl : mergedCompilationUnit.getImports()) { + allImports.add(new ImportInfo(importDecl.getNameAsString(), importDecl.isStatic(), importDecl.isAsterisk())); + } + + // Add imports from existing file (avoiding duplicates) + for (ImportDeclaration importDecl : existingCompilationUnit.getImports()) { + allImports.add(new ImportInfo(importDecl.getNameAsString(), importDecl.isStatic(), importDecl.isAsterisk())); + } + + // Clear existing imports and add sorted imports + mergedCompilationUnit.getImports().clear(); + + // Sort imports according to best practices and add them back + allImports.stream() + .sorted() + .forEach(importInfo -> mergedCompilationUnit.addImport( + importInfo.name(), importInfo.isStatic(), importInfo.isAsterisk())); + } + + private static int getImportPriority(String importName) { + if (importName.startsWith("java.")) { + return 10; + } else if (importName.startsWith("javax.")) { + return 20; + } else if (importName.startsWith("jakarta.")) { + return 30; + } else { + return 40; // Third-party and project imports + } + } + + private static void addPreservedElements(CompilationUnit existingCompilationUnit, CompilationUnit mergedCompilationUnit, String[] javadocTags) { + // Find the main type declarations + TypeDeclaration existingTypeDeclaration = findMainTypeDeclaration(existingCompilationUnit); + TypeDeclaration mergedTypeDeclaration = findMainTypeDeclaration(mergedCompilationUnit); + + if (existingTypeDeclaration instanceof ClassOrInterfaceDeclaration existingClassDeclaration && + mergedTypeDeclaration instanceof ClassOrInterfaceDeclaration mergedClassDeclaration) { + + // Add only non-generated members from the existing class to the end of merged class + for (BodyDeclaration member : existingClassDeclaration.getMembers()) { + if (!isGeneratedElement(member, javadocTags)) { + mergedClassDeclaration.addMember(member.clone()); + } + } + } + } + + private static TypeDeclaration findMainTypeDeclaration(CompilationUnit compilationUnit) { + // Return the first public type declaration, or the first type declaration if no public one exists + TypeDeclaration firstType = null; + for (TypeDeclaration typeDeclaration : compilationUnit.getTypes()) { + if (firstType == null) { + firstType = typeDeclaration; + } + if (typeDeclaration.isPublic()) { + return typeDeclaration; + } + } + return firstType; + } + + private static String readFileContent(File file, String fileEncoding) throws IOException { + if (fileEncoding != null) { + return Files.readString(file.toPath(), Charset.forName(fileEncoding)); + } else { + return Files.readString(file.toPath(), StandardCharsets.UTF_8); + } + } +} diff --git a/core/mybatis-generator-core/src/test/java/org/mybatis/generator/internal/JavaFileMergerTest.java b/core/mybatis-generator-core/src/test/java/org/mybatis/generator/internal/JavaFileMergerTest.java new file mode 100644 index 000000000..1838a2568 --- /dev/null +++ b/core/mybatis-generator-core/src/test/java/org/mybatis/generator/internal/JavaFileMergerTest.java @@ -0,0 +1,370 @@ +/* + * Copyright 2006-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mybatis.generator.internal; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.generator.config.MergeConstants; + +import static org.assertj.core.api.Assertions.assertThat; + +class JavaFileMergerTest { + + @Nested + class GetMergedSourceTests { + + @Test + void shouldAddNewGeneratedMethodsWhenMergingWithJavadocTag() throws Exception { + // Arrange + var existingFileContent = """ + package com.example; + + public class TestMapper { + public void customMethod() { + System.out.println("Custom method"); + } + } + """; + + var newFileContent = """ + package com.example; + + public class TestMapper { + /** + * @mbg.generated + */ + public int insert(Object record) { + return 0; + } + } + """; + + var javadocTags = MergeConstants.getOldElementTags(); + + // Act + var actual = JavaFileMerger.getMergedSource(newFileContent, existingFileContent, javadocTags); + + // Assert + var expected = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int insert(Object record) { + return 0; + } + + public void customMethod() { + System.out.println("Custom method"); + } + } + """; + assertThat(actual).isEqualTo(expected); + } + + @Test + void shouldMergeImportsCorrectly() throws Exception { + // Arrange + var existingFileContent = """ + package com.example; + + import java.util.Set; + import java.util.Date; + import java.sql.Connection; + + public class TestMapper { + public void customMethod() {} + } + """; + + var newFileContent = """ + package com.example; + + import java.util.List; + import java.util.Map; + import java.util.Date; + import java.sql.PreparedStatement; + + public class TestMapper { + /** + * @mbg.generated + */ + public Map getMap() { + return null; + } + } + """; + + var javadocTags = MergeConstants.getOldElementTags(); + + // Act + var actual = JavaFileMerger.getMergedSource(newFileContent, existingFileContent, javadocTags); + + // Assert + var expected = """ + package com.example; + + import java.sql.Connection; + import java.sql.PreparedStatement; + import java.util.Date; + import java.util.List; + import java.util.Map; + import java.util.Set; + + public class TestMapper { + + /** + * @mbg.generated + */ + public Map getMap() { + return null; + } + + public void customMethod() { + } + } + """; + assertThat(actual).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(strings = {"@ibatorgenerated", "@abatorgenerated", "@mbggenerated", "@mbg.generated"}) + void shouldPreserveCustomMethodsWithAllSupportedJavadocTags(String javadocTag) throws Exception { + // Arrange + var existingFileContent = String.format(""" + package com.example; + + public class TestMapper { + + /** + * %s + */ + public int oldGeneratedMethod() { + return 0; + } + + public void customMethod() { + System.out.println("Custom method"); + } + } + """, javadocTag); + + var newFileContent = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int newGeneratedMethod() { + return 1; + } + } + """; + + var javadocTags = MergeConstants.getOldElementTags(); + + // Act + var actual = JavaFileMerger.getMergedSource(newFileContent, existingFileContent, javadocTags); + + // Assert + var expected = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int newGeneratedMethod() { + return 1; + } + + public void customMethod() { + System.out.println("Custom method"); + } + } + """; + assertThat(actual).isEqualTo(expected); + } + + @ParameterizedTest + @ValueSource(strings = {"javax.annotation.Generated", "jakarta.annotation.Generated"}) + void shouldPreserveCustomMethodsWhenMergingWithGeneratedAnnotation(String annotationClass) throws Exception { + // Arrange + var existingFileContent = String.format(""" + package com.example; + + import %s; + + public class TestMapper { + + @Generated("MyBatis Generator") + public int deleteByPrimaryKey(Integer id) { + return 0; + } + + // This is a custom method that should be preserved + public void customMethod() { + System.out.println("Custom method"); + } + } + """, annotationClass); + + var newFileContent = String.format(""" + package com.example; + + import %s; + + public class TestMapper { + + @Generated("MyBatis Generator") + public int deleteByPrimaryKey(Integer id) { + // Updated implementation + return 1; + } + + @Generated("MyBatis Generator") + public int insert(Object record) { + return 0; + } + } + """, annotationClass); + + var javadocTags = MergeConstants.getOldElementTags(); + + // Act + var actual = JavaFileMerger.getMergedSource(newFileContent, existingFileContent, javadocTags); + + // Assert + var expected = String.format(""" + package com.example; + + import %s; + + public class TestMapper { + + @Generated("MyBatis Generator") + public int deleteByPrimaryKey(Integer id) { + // Updated implementation + return 1; + } + + @Generated("MyBatis Generator") + public int insert(Object record) { + return 0; + } + + // This is a custom method that should be preserved + public void customMethod() { + System.out.println("Custom method"); + } + } + """, annotationClass); + assertThat(actual).isEqualTo(expected); + } + + @Test + void shouldPreserveMultipleCustomMethodsWhenMerging() throws Exception { + // Arrange + var existingFileContent = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int generatedMethod() { + return 0; + } + + public void customMethod1() { + System.out.println("Custom method 1"); + } + + public void customMethod2() { + System.out.println("Custom method 2"); + } + } + """; + + var newFileContent = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int generatedMethod() { + return 1; // Updated + } + + /** + * @mbg.generated + */ + public int newGeneratedMethod() { + return 0; + } + } + """; + + var javadocTags = MergeConstants.getOldElementTags(); + + // Act + var actual = JavaFileMerger.getMergedSource(newFileContent, existingFileContent, javadocTags); + + // Assert + var expected = """ + package com.example; + + public class TestMapper { + + /** + * @mbg.generated + */ + public int generatedMethod() { + // Updated + return 1; + } + + /** + * @mbg.generated + */ + public int newGeneratedMethod() { + return 0; + } + + public void customMethod1() { + System.out.println("Custom method 1"); + } + + public void customMethod2() { + System.out.println("Custom method 2"); + } + } + """; + assertThat(actual).isEqualTo(expected); + } + } +} diff --git a/core/pom.xml b/core/pom.xml index 3781d4942..d311da2c3 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -163,7 +163,6 @@ com.github.javaparser javaparser-core 3.27.0 - test ch.qos.logback