From ac905763b934f46c45794aeb1f01836f084bf1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Fri, 31 Jul 2026 18:45:55 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20MyBatis=20=E7=94=9F?= =?UTF-8?q?=E6=88=90=E8=BE=93=E5=87=BA=E5=B8=83=E5=B1=80=E4=B8=8E=E7=BB=93?= =?UTF-8?q?=E6=9E=9C=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyBatisGeneratorWindow.java | 233 ++++++++++++++---- .../application/DirectoryRisk.java | 21 ++ .../application/GenerateArtifactsRequest.java | 14 +- .../application/GenerateArtifactsUseCase.java | 20 +- .../application/GenerationPlan.java | 30 +++ .../application/GenerationPlanner.java | 47 ++++ .../application/GenerationResult.java | 21 ++ .../application/OutputLayout.java | 24 ++ .../application/OutputLayoutPolicy.java | 39 +++ .../application/OutputSnapshot.java | 103 ++++++++ ...yBatisGeneratorWindowArchitectureTest.java | 27 ++ .../application/GenerationPlannerTest.java | 52 ++++ .../application/GeneratorRequestTest.java | 16 +- .../application/GeneratorUseCaseTest.java | 21 +- .../application/OutputLayoutPolicyTest.java | 52 ++++ .../application/OutputSnapshotTest.java | 43 ++++ 16 files changed, 696 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/DirectoryRisk.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlan.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlanner.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationResult.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayout.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshot.java create mode 100644 src/test/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindowArchitectureTest.java create mode 100644 src/test/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlannerTest.java create mode 100644 src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java create mode 100644 src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshotTest.java diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java index 554336c..9c6583b 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java @@ -2,6 +2,11 @@ package com.example.workutils.biz.mybatisgenerator; import com.example.workutils.biz.mybatisgenerator.application.GenerateArtifactsRequest; import com.example.workutils.biz.mybatisgenerator.application.GenerateArtifactsUseCase; +import com.example.workutils.biz.mybatisgenerator.application.GenerationPlan; +import com.example.workutils.biz.mybatisgenerator.application.GenerationPlanner; +import com.example.workutils.biz.mybatisgenerator.application.GenerationResult; +import com.example.workutils.biz.mybatisgenerator.application.OutputLayout; +import com.example.workutils.biz.mybatisgenerator.application.OutputLayoutPolicy; import com.example.workutils.biz.mybatisgenerator.application.QueryTablesRequest; import com.example.workutils.biz.mybatisgenerator.application.QueryTablesUseCase; import com.example.workutils.biz.mybatisgenerator.presentation.DatasourceSummary; @@ -34,7 +39,6 @@ import javafx.stage.Stage; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -57,8 +61,10 @@ public class MyBatisGeneratorWindow { private final WorkUtilsProperties properties; private final QueryTablesUseCase queryTablesUseCase; private final GenerateArtifactsUseCase generateArtifactsUseCase; + private final OutputLayoutPolicy outputLayoutPolicy; + private final GenerationPlanner generationPlanner; private final AsyncOperationController> queryOperation; - private final AsyncOperationController generateOperation; + private final AsyncOperationController generateOperation; // ---- UI 组件 ---- private TextField patternField; @@ -67,6 +73,8 @@ public class MyBatisGeneratorWindow { private TextField modelDirField; private TextField xmlDirField; private TextField mapperDirField; + private TextField projectRootField; + private CheckBox customOutputCb; private TreeView projectTree; private ComboBox datasourceCmb; private Label dbUrlLabel; @@ -115,9 +123,11 @@ public class MyBatisGeneratorWindow { this.generateArtifactsUseCase = new GenerateArtifactsUseCase( request -> callback.generate( request.tables(), - request.modelDirectory(), - request.xmlDirectory(), - request.mapperDirectory())); + request.outputLayout().modelDirectory().toString(), + request.outputLayout().xmlDirectory().toString(), + request.outputLayout().mapperDirectory().toString())); + this.outputLayoutPolicy = new OutputLayoutPolicy(); + this.generationPlanner = new GenerationPlanner(); this.queryOperation = new AsyncOperationController<>( AppExecutors.io(), Platform::runLater); this.generateOperation = new AsyncOperationController<>( @@ -429,7 +439,32 @@ public class MyBatisGeneratorWindow { Label dirLabel = new Label("输出目录设置:"); dirLabel.setFont(Theme.heading(fsSection())); dirLabel.setTextFill(Theme.TEXT_PRIMARY); - box.getChildren().add(dirLabel); + projectRootField = new TextField(); + projectRootField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + HBox.setHgrow(projectRootField, Priority.ALWAYS); + projectRootField.setOnAction(e -> refreshDerivedDirectories()); + projectRootField.focusedProperty().addListener((obs, oldValue, focused) -> { + if (!focused) refreshDerivedDirectories(); + }); + + Button browseRootBtn = StyledButton.small( + "浏览...", Font.font("Microsoft YaHei", fsBody() - 1)); + browseRootBtn.setOnAction(e -> onBrowseProjectRoot()); + Button useTreeBtn = StyledButton.small( + "使用选中项目", Font.font("Microsoft YaHei", fsBody() - 1)); + useTreeBtn.setOnAction(e -> useSelectedProjectRoot()); + Label rootLabel = new Label("项目根目录:"); + rootLabel.setPrefWidth(110); + HBox rootRow = new HBox( + Theme.S2, rootLabel, projectRootField, browseRootBtn, useTreeBtn); + rootRow.setAlignment(Pos.CENTER_LEFT); + + customOutputCb = new CheckBox("自定义输出目录(高级)"); + customOutputCb.setSelected(false); + customOutputCb.selectedProperty().addListener((obs, oldValue, custom) -> { + setDirectoryFieldsEditable(custom); + if (!custom) refreshDerivedDirectories(); + }); HBox modelRow = createDirRow("Model (PO)", fm -> modelDirField = fm); modelDirField = (TextField) modelRow.getChildren().get(1); @@ -438,9 +473,12 @@ public class MyBatisGeneratorWindow { HBox mapperRow = createDirRow("Mapper 接口", fm -> mapperDirField = fm); mapperDirField = (TextField) mapperRow.getChildren().get(1); - loadDefaultDirs(); + initializeProjectRoot(); + setDirectoryFieldsEditable(false); - box.getChildren().addAll(modelRow, xmlRow, mapperRow); + box.getChildren().addAll( + dirLabel, rootRow, customOutputCb, + modelRow, xmlRow, mapperRow); return box; } @@ -458,6 +496,7 @@ public class MyBatisGeneratorWindow { Button browseBtn = StyledButton.small("浏览...", Font.font("Microsoft YaHei", fsBody() - 1)); browseBtn.setOnAction(e -> onBrowseDir(field)); + browseBtn.disableProperty().bind(field.editableProperty().not()); Button fillBtn = StyledButton.small("填充", Font.font("Microsoft YaHei", fsBody() - 1)); fillBtn.setOnAction(e -> { @@ -469,44 +508,85 @@ public class MyBatisGeneratorWindow { } } }); + fillBtn.disableProperty().bind(field.editableProperty().not()); if (fieldRef != null) fieldRef.accept(field); row.getChildren().addAll(lbl, field, browseBtn, fillBtn); return row; } - private void loadDefaultDirs() { - Properties p = new Properties(); - // 1. classpath 内置默认值 - try (InputStream is = getClass().getClassLoader().getResourceAsStream("mybatis-generator.properties")) { - if (is != null) p.load(is); - } catch (Exception ignored) {} - // 2. data/ 外部配置覆盖 - Path dataFile = properties.getMybatisGeneratorFilePath(); - if (java.nio.file.Files.exists(dataFile)) { - try (InputStream is = new java.io.FileInputStream(dataFile.toFile())) { - Properties externalProps = new Properties(); - externalProps.load(is); - p.putAll(externalProps); - } catch (Exception ignored) {} + private void initializeProjectRoot() { + Path root = Paths.get("").toAbsolutePath().normalize(); + try { + List paths = fileOperationHelper.getProjectPaths(); + if (!paths.isEmpty() && Files.isDirectory(Paths.get(paths.get(0)))) { + root = Paths.get(paths.get(0)).toAbsolutePath().normalize(); + } + } catch (Exception ignored) { } - - setDirField(modelDirField, p.getProperty("model_target_project", "./generator")); - setDirField(xmlDirField, p.getProperty("sqlmapper_target_project", "./generator")); - setDirField(mapperDirField, p.getProperty("dao_target_project", "./generator")); + projectRootField.setText(root.toString().replace('\\', '/')); + refreshDerivedDirectories(); } - private void setDirField(TextField field, String dir) { - if (field == null || dir == null) return; - if (dir.startsWith("./") || dir.startsWith(".\\")) { - field.setText(new File(dir).getAbsolutePath().replace('\\', '/')); - } else { - field.setText(dir.replace('\\', '/')); + private void setDirectoryFieldsEditable(boolean editable) { + modelDirField.setEditable(editable); + xmlDirField.setEditable(editable); + mapperDirField.setEditable(editable); + } + + private void refreshDerivedDirectories() { + if (customOutputCb != null && customOutputCb.isSelected()) return; + try { + OutputLayout layout = outputLayoutPolicy.derive( + Paths.get(projectRootField.getText().trim())); + setDirectoryFields(layout); + } catch (IllegalArgumentException error) { + modelDirField.clear(); + xmlDirField.clear(); + mapperDirField.clear(); } } + private void setDirectoryFields(OutputLayout layout) { + modelDirField.setText(pathText(layout.modelDirectory())); + xmlDirField.setText(pathText(layout.xmlDirectory())); + mapperDirField.setText(pathText(layout.mapperDirectory())); + } + + private String pathText(Path path) { + return path.toString().replace('\\', '/'); + } + // ==================== 事件 ==================== + private void onBrowseProjectRoot() { + DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle("选择项目根目录"); + Path current = parseProjectRoot(); + if (current != null && Files.isDirectory(current)) { + chooser.setInitialDirectory(current.toFile()); + } + File selected = chooser.showDialog(stage); + if (selected != null) { + projectRootField.setText(pathText(selected.toPath())); + refreshDerivedDirectories(); + } + } + + private void useSelectedProjectRoot() { + TreeItem selected = projectTree.getSelectionModel().getSelectedItem(); + String path = buildTreePath(selected); + if (path != null && Files.isDirectory(Paths.get(path))) { + projectRootField.setText(pathText(Paths.get(path))); + refreshDerivedDirectories(); + } + } + + private Path parseProjectRoot() { + String text = projectRootField == null ? "" : projectRootField.getText().trim(); + return text.isEmpty() ? null : Paths.get(text).toAbsolutePath().normalize(); + } + private void onBrowseDir(TextField targetField) { DirectoryChooser chooser = new DirectoryChooser(); chooser.setTitle("选择目录"); @@ -565,28 +645,97 @@ public class MyBatisGeneratorWindow { private void onGenerate() { try { + List selectedTables = tableItems.stream() + .filter(TableItem::isSelected) + .map(TableItem::getTableName) + .collect(Collectors.toList()); + OutputLayout layout = buildOutputLayout(); GenerateArtifactsRequest request = new GenerateArtifactsRequest( - tableItems.stream() - .filter(TableItem::isSelected) - .map(TableItem::getTableName) - .collect(Collectors.toList()), - getDirText(modelDirField), - getDirText(xmlDirField), - getDirText(mapperDirField)); + selectedTables, layout); + GenerationPlan plan = generationPlanner.plan(selectedTables, layout); + if (!confirmGeneration(plan)) return; generateOperation.start( "正在生成代码", context -> generateArtifactsUseCase.execute(request), - result -> showInfo("生成结果", result), + result -> showInfo("生成结果", formatGenerationResult(result)), error -> showAlert("生成失败: " + error.getMessage())); - } catch (IllegalArgumentException error) { + } catch (RuntimeException error) { showAlert(error.getMessage()); } } + private OutputLayout buildOutputLayout() { + Path root = parseProjectRoot(); + if (root == null) { + throw new IllegalArgumentException("请选择项目根目录"); + } + if (!customOutputCb.isSelected()) { + return outputLayoutPolicy.derive(root); + } + return outputLayoutPolicy.custom( + root, + Paths.get(getDirText(modelDirField)), + Paths.get(getDirText(xmlDirField)), + Paths.get(getDirText(mapperDirField))); + } + + private boolean confirmGeneration(GenerationPlan plan) { + Alert alert = new Alert(Alert.AlertType.CONFIRMATION); + alert.setTitle("确认生成"); + alert.setHeaderText("即将为 " + plan.tables().size() + " 个表生成代码"); + alert.setContentText(buildGenerationSummary(plan)); + alert.initOwner(stage); + return alert.showAndWait().filter(ButtonType.OK::equals).isPresent(); + } + + private String buildGenerationSummary(GenerationPlan plan) { + return "表: " + String.join(", ", plan.tables()) + + "\n\nModel: " + formatRisk(plan.modelRisk()) + + "\nXML Mapper: " + formatRisk(plan.xmlRisk()) + + "\nMapper 接口: " + formatRisk(plan.mapperRisk()) + + "\n\n已有文件可能被生成器覆盖,请确认后继续。"; + } + + private String formatRisk( + com.example.workutils.biz.mybatisgenerator.application.DirectoryRisk risk) { + String state = risk.willCreate() + ? "待创建" + : "已有 " + risk.existingFileCount() + " 个相关文件"; + return pathText(risk.directory()) + "(" + state + ")"; + } + + private String formatGenerationResult(GenerationResult result) { + StringBuilder text = new StringBuilder(); + text.append("新增文件: ").append(result.createdFiles().size()).append('\n'); + appendFiles(text, result.createdFiles()); + text.append("\n更新文件: ").append(result.updatedFiles().size()).append('\n'); + appendFiles(text, result.updatedFiles()); + text.append("\n未变化文件: ").append(result.unchangedFiles().size()).append('\n'); + if (!result.warnings().isEmpty()) { + text.append("\n警告:\n"); + result.warnings().forEach(warning -> text.append("- ").append(warning).append('\n')); + } + if (!result.generatorMessage().isBlank()) { + text.append("\n生成器消息:\n").append(result.generatorMessage()); + } + return text.toString(); + } + + private void appendFiles(StringBuilder text, List files) { + int visibleCount = Math.min(files.size(), 20); + for (int index = 0; index < visibleCount; index++) { + text.append("- ").append(pathText(files.get(index))).append('\n'); + } + if (files.size() > visibleCount) { + text.append("... 另有 ").append(files.size() - visibleCount).append(" 个文件\n"); + } + } + private void resetForm() { patternField.clear(); tableItems.clear(); - loadDefaultDirs(); + customOutputCb.setSelected(false); + initializeProjectRoot(); } private String getDirText(TextField field) { diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/DirectoryRisk.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/DirectoryRisk.java new file mode 100644 index 0000000..a2e65f6 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/DirectoryRisk.java @@ -0,0 +1,21 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.nio.file.Path; +import java.util.Objects; + +public record DirectoryRisk( + Path directory, + boolean exists, + boolean writable, + boolean willCreate, + long existingFileCount) { + + public DirectoryRisk { + directory = Objects.requireNonNull(directory, "directory") + .toAbsolutePath() + .normalize(); + if (existingFileCount < 0) { + throw new IllegalArgumentException("existingFileCount 不能为负数"); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java index ff6cdb3..c4ee628 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsRequest.java @@ -5,23 +5,13 @@ import java.util.Objects; public record GenerateArtifactsRequest( List tables, - String modelDirectory, - String xmlDirectory, - String mapperDirectory) { + OutputLayout outputLayout) { public GenerateArtifactsRequest { tables = List.copyOf(Objects.requireNonNull(tables, "tables")); if (tables.isEmpty()) { throw new IllegalArgumentException("请至少选择一个表"); } - modelDirectory = directoryOrDefault(modelDirectory); - xmlDirectory = directoryOrDefault(xmlDirectory); - mapperDirectory = directoryOrDefault(mapperDirectory); - } - - private static String directoryOrDefault(String value) { - return value == null || value.isBlank() - ? "./generator" - : value.trim(); + outputLayout = Objects.requireNonNull(outputLayout, "outputLayout"); } } diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java index 36df585..44e3325 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerateArtifactsUseCase.java @@ -10,12 +10,26 @@ public final class GenerateArtifactsUseCase { } private final Gateway gateway; + private final GenerationPlanner planner; public GenerateArtifactsUseCase(Gateway gateway) { - this.gateway = Objects.requireNonNull(gateway, "gateway"); + this(gateway, new GenerationPlanner()); } - public String execute(GenerateArtifactsRequest request) throws Exception { - return gateway.generate(Objects.requireNonNull(request, "request")); + GenerateArtifactsUseCase( + Gateway gateway, + GenerationPlanner planner) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.planner = Objects.requireNonNull(planner, "planner"); + } + + public GenerationResult execute(GenerateArtifactsRequest request) throws Exception { + GenerateArtifactsRequest safeRequest = + Objects.requireNonNull(request, "request"); + planner.plan(safeRequest.tables(), safeRequest.outputLayout()); + OutputSnapshot before = OutputSnapshot.capture(safeRequest.outputLayout()); + String message = gateway.generate(safeRequest); + OutputSnapshot after = OutputSnapshot.capture(safeRequest.outputLayout()); + return before.compare(after, message); } } diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlan.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlan.java new file mode 100644 index 0000000..1d1c7a5 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlan.java @@ -0,0 +1,30 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.util.List; +import java.util.Objects; + +public record GenerationPlan( + List tables, + DirectoryRisk modelRisk, + DirectoryRisk xmlRisk, + DirectoryRisk mapperRisk) { + + public GenerationPlan { + tables = List.copyOf(Objects.requireNonNull(tables, "tables")); + modelRisk = Objects.requireNonNull(modelRisk, "modelRisk"); + xmlRisk = Objects.requireNonNull(xmlRisk, "xmlRisk"); + mapperRisk = Objects.requireNonNull(mapperRisk, "mapperRisk"); + } + + public void validateWritableDirectories() { + validate(modelRisk); + validate(xmlRisk); + validate(mapperRisk); + } + + private void validate(DirectoryRisk risk) { + if (risk.exists() && !risk.writable()) { + throw new IllegalArgumentException("输出目录不可写: " + risk.directory()); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlanner.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlanner.java new file mode 100644 index 0000000..8901700 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlanner.java @@ -0,0 +1,47 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.stream.Stream; + +public final class GenerationPlanner { + + public GenerationPlan plan(List tables, OutputLayout layout) { + Objects.requireNonNull(layout, "layout"); + GenerationPlan plan = new GenerationPlan( + List.copyOf(Objects.requireNonNull(tables, "tables")), + inspect(layout.modelDirectory(), ".java"), + inspect(layout.xmlDirectory(), ".xml"), + inspect(layout.mapperDirectory(), ".java")); + plan.validateWritableDirectories(); + return plan; + } + + private DirectoryRisk inspect(Path directory, String extension) { + boolean exists = Files.isDirectory(directory); + return new DirectoryRisk( + directory, + exists, + !exists || Files.isWritable(directory), + !exists, + exists ? countFiles(directory, extension) : 0); + } + + private long countFiles(Path directory, String extension) { + try (Stream paths = Files.walk(directory)) { + String suffix = extension.toLowerCase(Locale.ROOT); + return paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString() + .toLowerCase(Locale.ROOT).endsWith(suffix)) + .count(); + } catch (IOException error) { + throw new UncheckedIOException( + "无法检查输出目录: " + directory, error); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationResult.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationResult.java new file mode 100644 index 0000000..fe15e29 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/GenerationResult.java @@ -0,0 +1,21 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record GenerationResult( + List createdFiles, + List updatedFiles, + List unchangedFiles, + List warnings, + String generatorMessage) { + + public GenerationResult { + createdFiles = List.copyOf(Objects.requireNonNull(createdFiles, "createdFiles")); + updatedFiles = List.copyOf(Objects.requireNonNull(updatedFiles, "updatedFiles")); + unchangedFiles = List.copyOf(Objects.requireNonNull(unchangedFiles, "unchangedFiles")); + warnings = List.copyOf(Objects.requireNonNull(warnings, "warnings")); + generatorMessage = generatorMessage == null ? "" : generatorMessage; + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayout.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayout.java new file mode 100644 index 0000000..ed6b0e6 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayout.java @@ -0,0 +1,24 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.nio.file.Path; +import java.util.Objects; + +public record OutputLayout( + Path projectRoot, + Path modelDirectory, + Path xmlDirectory, + Path mapperDirectory) { + + public OutputLayout { + projectRoot = normalize(projectRoot, "projectRoot"); + modelDirectory = normalize(modelDirectory, "modelDirectory"); + xmlDirectory = normalize(xmlDirectory, "xmlDirectory"); + mapperDirectory = normalize(mapperDirectory, "mapperDirectory"); + } + + private static Path normalize(Path path, String name) { + return Objects.requireNonNull(path, name) + .toAbsolutePath() + .normalize(); + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java new file mode 100644 index 0000000..405298c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicy.java @@ -0,0 +1,39 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; + +public final class OutputLayoutPolicy { + + public OutputLayout derive(Path projectRoot) { + Path root = requireProjectRoot(projectRoot); + return new OutputLayout( + root, + root.resolve("src/main/java"), + root.resolve("src/main/resources"), + root.resolve("src/main/java")); + } + + public OutputLayout custom( + Path projectRoot, + Path modelDirectory, + Path xmlDirectory, + Path mapperDirectory) { + return new OutputLayout( + requireProjectRoot(projectRoot), + Objects.requireNonNull(modelDirectory, "modelDirectory"), + Objects.requireNonNull(xmlDirectory, "xmlDirectory"), + Objects.requireNonNull(mapperDirectory, "mapperDirectory")); + } + + private Path requireProjectRoot(Path projectRoot) { + Path root = Objects.requireNonNull(projectRoot, "projectRoot") + .toAbsolutePath() + .normalize(); + if (!Files.isDirectory(root)) { + throw new IllegalArgumentException("项目根目录不存在或不是目录: " + root); + } + return root; + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshot.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshot.java new file mode 100644 index 0000000..e091328 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshot.java @@ -0,0 +1,103 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +public final class OutputSnapshot { + + private final Map files; + private final List warnings; + + private OutputSnapshot( + Map files, + List warnings) { + this.files = Map.copyOf(files); + this.warnings = List.copyOf(warnings); + } + + public static OutputSnapshot capture(OutputLayout layout) { + Map files = new LinkedHashMap<>(); + List warnings = new ArrayList<>(); + Set roots = new LinkedHashSet<>(List.of( + layout.modelDirectory(), + layout.xmlDirectory(), + layout.mapperDirectory())); + for (Path root : roots) { + captureRoot(root, files, warnings); + } + return new OutputSnapshot(files, warnings); + } + + public GenerationResult compare( + OutputSnapshot after, + String generatorMessage) { + List created = new ArrayList<>(); + List updated = new ArrayList<>(); + List unchanged = new ArrayList<>(); + for (Map.Entry entry : after.files.entrySet()) { + FileFingerprint previous = files.get(entry.getKey()); + if (previous == null) { + created.add(entry.getKey()); + } else if (!previous.equals(entry.getValue())) { + updated.add(entry.getKey()); + } else { + unchanged.add(entry.getKey()); + } + } + created.sort(Path::compareTo); + updated.sort(Path::compareTo); + unchanged.sort(Path::compareTo); + List combinedWarnings = new ArrayList<>(warnings); + combinedWarnings.addAll(after.warnings); + return new GenerationResult( + created, updated, unchanged, + combinedWarnings, generatorMessage); + } + + private static void captureRoot( + Path root, + Map files, + List warnings) { + if (!Files.isDirectory(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + paths.filter(Files::isRegularFile) + .filter(OutputSnapshot::isGeneratedSource) + .forEach(path -> captureFile(path, files, warnings)); + } catch (IOException error) { + warnings.add("无法扫描输出目录 " + root + ": " + error.getMessage()); + } + } + + private static void captureFile( + Path path, + Map files, + List warnings) { + Path normalized = path.toAbsolutePath().normalize(); + try { + files.put(normalized, new FileFingerprint( + Files.size(normalized), + Files.getLastModifiedTime(normalized).toMillis())); + } catch (IOException error) { + warnings.add("无法读取输出文件 " + normalized + ": " + error.getMessage()); + } + } + + private static boolean isGeneratedSource(Path path) { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".java") || name.endsWith(".xml"); + } + + private record FileFingerprint(long size, long modifiedMillis) { + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindowArchitectureTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindowArchitectureTest.java new file mode 100644 index 0000000..1fbd49e --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindowArchitectureTest.java @@ -0,0 +1,27 @@ +package com.example.workutils.biz.mybatisgenerator; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MyBatisGeneratorWindowArchitectureTest { + + @Test + void usesProjectRootPolicyAndAdvancedCustomDirectoryMode() throws IOException { + String source = Files.readString(Path.of( + "src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java")); + + assertTrue(source.contains("projectRootField")); + assertTrue(source.contains("customOutputCb")); + assertTrue(source.contains("OutputLayoutPolicy")); + assertTrue(source.contains("setDirectoryFieldsEditable")); + assertTrue(source.contains("buildGenerationSummary")); + assertTrue(source.contains("formatGenerationResult")); + assertFalse(source.contains("Files.walk")); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlannerTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlannerTest.java new file mode 100644 index 0000000..b41fc61 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GenerationPlannerTest.java @@ -0,0 +1,52 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GenerationPlannerTest { + + @TempDir + Path tempDir; + + @Test + void reportsExistingFilesAndDirectoriesWaitingToBeCreated() throws IOException { + Path javaRoot = Files.createDirectories(tempDir.resolve("src/main/java")); + Files.writeString(javaRoot.resolve("Account.java"), "class Account {}"); + Files.writeString(javaRoot.resolve("README.txt"), "ignored"); + OutputLayout layout = new OutputLayout( + tempDir, + javaRoot, + tempDir.resolve("src/main/resources"), + javaRoot); + + GenerationPlan plan = new GenerationPlanner().plan( + List.of("account", "trade"), layout); + + assertEquals(List.of("account", "trade"), plan.tables()); + assertEquals(1, plan.modelRisk().existingFileCount()); + assertEquals(1, plan.mapperRisk().existingFileCount()); + assertFalse(plan.xmlRisk().exists()); + assertTrue(plan.xmlRisk().willCreate()); + } + + @Test + void rejectsAPlanContainingAnUnwritableExistingDirectory() { + DirectoryRisk blocked = new DirectoryRisk( + tempDir.resolve("blocked"), true, false, false, 0); + GenerationPlan plan = new GenerationPlan( + List.of("account"), blocked, blocked, blocked); + + assertThrows(IllegalArgumentException.class, + plan::validateWritableDirectories); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java index 5fe3f72..28f82ab 100644 --- a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorRequestTest.java @@ -1,7 +1,9 @@ package com.example.workutils.biz.mybatisgenerator.application; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -10,6 +12,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; class GeneratorRequestTest { + @TempDir + Path tempDir; + @Test void queryRequiresAndTrimsPattern() { assertEquals("trade%", @@ -19,23 +24,22 @@ class GeneratorRequestTest { } @Test - void generateCopiesTablesAndDefaultsDirectories() { + void generateCopiesTablesAndKeepsOutputLayout() { List tables = new ArrayList<>(List.of("trade_order")); + OutputLayout layout = new OutputLayoutPolicy().derive(tempDir); GenerateArtifactsRequest request = new GenerateArtifactsRequest( - tables, " ", null, "./custom"); + tables, layout); tables.add("trade_item"); assertEquals(List.of("trade_order"), request.tables()); - assertEquals("./generator", request.modelDirectory()); - assertEquals("./generator", request.xmlDirectory()); - assertEquals("./custom", request.mapperDirectory()); + assertEquals(layout, request.outputLayout()); } @Test void generateRequiresSelectedTable() { assertThrows(IllegalArgumentException.class, () -> new GenerateArtifactsRequest( - List.of(), "a", "b", "c")); + List.of(), new OutputLayoutPolicy().derive(tempDir))); } } diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java index 0999645..48424d2 100644 --- a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/GeneratorUseCaseTest.java @@ -1,7 +1,10 @@ package com.example.workutils.biz.mybatisgenerator.application; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -10,6 +13,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class GeneratorUseCaseTest { + @TempDir + Path tempDir; + @Test void queryDelegatesAndCopiesResult() throws Exception { List gatewayResult = @@ -25,20 +31,27 @@ class GeneratorUseCaseTest { } @Test - void generateDelegatesCompleteRequest() throws Exception { + void generateDelegatesRequestAndReturnsFileDifference() throws Exception { AtomicReference received = new AtomicReference<>(); + OutputLayout layout = new OutputLayoutPolicy().derive(tempDir); GenerateArtifactsUseCase useCase = new GenerateArtifactsUseCase(request -> { received.set(request); + Files.createDirectories(request.outputLayout().modelDirectory()); + Files.writeString( + request.outputLayout().modelDirectory().resolve("TradeOrder.java"), + "class TradeOrder {}"); return "generated"; }); GenerateArtifactsRequest request = new GenerateArtifactsRequest( - List.of("trade_order"), "model", "xml", "mapper"); + List.of("trade_order"), layout); - String result = useCase.execute(request); + GenerationResult result = useCase.execute(request); - assertEquals("generated", result); + assertEquals("generated", result.generatorMessage()); + assertEquals(List.of(layout.modelDirectory().resolve("TradeOrder.java")), + result.createdFiles()); assertEquals(request, received.get()); } } diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java new file mode 100644 index 0000000..6a5833f --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputLayoutPolicyTest.java @@ -0,0 +1,52 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OutputLayoutPolicyTest { + + @TempDir + Path tempDir; + + @Test + void derivesStandardDirectoriesFromProjectRoot() { + OutputLayout layout = new OutputLayoutPolicy().derive(tempDir); + + Path root = tempDir.toAbsolutePath().normalize(); + assertEquals(root, layout.projectRoot()); + assertEquals(root.resolve("src/main/java"), layout.modelDirectory()); + assertEquals(root.resolve("src/main/resources"), layout.xmlDirectory()); + assertEquals(root.resolve("src/main/java"), layout.mapperDirectory()); + } + + @Test + void rejectsARegularFileAsProjectRoot() throws IOException { + Path file = Files.createFile(tempDir.resolve("pom.xml")); + + assertThrows(IllegalArgumentException.class, + () -> new OutputLayoutPolicy().derive(file)); + } + + @Test + void keepsExplicitCustomDirectories() { + OutputLayout layout = new OutputLayoutPolicy().custom( + tempDir, + tempDir.resolve("model-output"), + tempDir.resolve("xml-output"), + tempDir.resolve("mapper-output")); + + assertEquals(tempDir.resolve("model-output").toAbsolutePath().normalize(), + layout.modelDirectory()); + assertEquals(tempDir.resolve("xml-output").toAbsolutePath().normalize(), + layout.xmlDirectory()); + assertEquals(tempDir.resolve("mapper-output").toAbsolutePath().normalize(), + layout.mapperDirectory()); + } +} diff --git a/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshotTest.java b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshotTest.java new file mode 100644 index 0000000..d08084c --- /dev/null +++ b/src/test/java/com/example/workutils/biz/mybatisgenerator/application/OutputSnapshotTest.java @@ -0,0 +1,43 @@ +package com.example.workutils.biz.mybatisgenerator.application; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class OutputSnapshotTest { + + @TempDir + Path tempDir; + + @Test + void classifiesCreatedUpdatedAndUnchangedFiles() throws IOException { + Path javaRoot = Files.createDirectories(tempDir.resolve("src/main/java")); + Path xmlRoot = Files.createDirectories(tempDir.resolve("src/main/resources")); + Path updated = Files.writeString(javaRoot.resolve("Account.java"), "before"); + Path unchanged = Files.writeString(xmlRoot.resolve("Account.xml"), "same"); + OutputLayout layout = new OutputLayout(tempDir, javaRoot, xmlRoot, javaRoot); + OutputSnapshot before = OutputSnapshot.capture(layout); + + Files.writeString(updated, "after-content"); + Files.setLastModifiedTime(updated, FileTime.fromMillis( + Files.getLastModifiedTime(updated).toMillis() + 2_000)); + Path created = Files.writeString(javaRoot.resolve("AccountMapper.java"), "new"); + OutputSnapshot after = OutputSnapshot.capture(layout); + + GenerationResult result = before.compare(after, "generated"); + + assertEquals(java.util.List.of(created.toAbsolutePath().normalize()), + result.createdFiles()); + assertEquals(java.util.List.of(updated.toAbsolutePath().normalize()), + result.updatedFiles()); + assertEquals(java.util.List.of(unchanged.toAbsolutePath().normalize()), + result.unchangedFiles()); + assertEquals("generated", result.generatorMessage()); + } +}