diff --git a/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfig.java b/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfig.java index e2314c5..9da13a3 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfig.java +++ b/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfig.java @@ -53,8 +53,12 @@ public record FractalxConfig( ) { /** Per-service overrides read from fractalx-config.yml. */ - public record ServiceOverride(int port, boolean tracingEnabled) { + public record ServiceOverride(int port, boolean tracingEnabled, + String datasourceUrl, String datasourceUsername, + String datasourcePassword, String datasourceDriver) { public boolean hasPort() { return port > 0; } + public boolean hasDatasource() { return datasourceUrl != null && !datasourceUrl.isBlank(); } + public boolean isH2() { return datasourceUrl != null && datasourceUrl.startsWith("jdbc:h2"); } } // ── Defaults ───────────────────────────────────────────────────────────── diff --git a/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfigReader.java b/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfigReader.java index 7401b22..3b02453 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfigReader.java +++ b/fractalx-core/src/main/java/org/fractalx/core/config/FractalxConfigReader.java @@ -124,7 +124,16 @@ private Map readServiceOverrides(Map dsMap) { + Object dsU = dsMap.get("url"); if (dsU != null) dsUrl = dsU.toString(); + Object dsN = dsMap.get("username"); if (dsN != null) dsUsername = dsN.toString(); + Object dsP = dsMap.get("password"); if (dsP != null) dsPassword = dsP.toString(); + Object dsD = dsMap.get("driver-class-name"); if (dsD != null) dsDriver = dsD.toString(); + } + result.put(name, new FractalxConfig.ServiceOverride(port, tracingEnabled, + dsUrl, dsUsername, dsPassword, dsDriver)); }); return result; } diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DependencyManager.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DependencyManager.java index b57ac0e..353b6d8 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DependencyManager.java +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DependencyManager.java @@ -7,6 +7,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.stream.Stream; /** * Provisions required infrastructure libraries (Redis, MySQL) @@ -56,6 +57,132 @@ public void provisionAop(FractalModule module, Path serviceRoot) { injectDependency(module, serviceRoot, "spring-boot-starter-aop", dependency); } + /** + * Walks all {@code .java} files under {@code serviceRoot/src/main/java} and provisions + * any implied dependencies that are referenced in import statements but not yet present + * in {@code pom.xml}. + * + *

Currently detects: + *

+ */ + public void provisionImpliedDependencies(FractalModule module, Path serviceRoot) { + Path srcMainJava = serviceRoot.resolve("src/main/java"); + if (!Files.exists(srcMainJava)) return; + + boolean needsLombok = false; + boolean needsValidation = false; + + try (Stream files = Files.walk(srcMainJava)) { + for (Path javaFile : files.filter(p -> p.toString().endsWith(".java")) + .toList()) { + String content = Files.readString(javaFile); + if (content.contains("import lombok.")) needsLombok = true; + if (content.contains("import jakarta.validation.")) needsValidation = true; + if (needsLombok && needsValidation) break; + } + } catch (IOException e) { + log.error("Failed to scan sources for implied dependencies in {}", module.getServiceName(), e); + return; + } + + if (needsLombok) { + String lombokXml = """ + + org.projectlombok + lombok + true + """; + injectDependency(module, serviceRoot, "org.projectlombok", lombokXml); + // Also wire Lombok as an annotation processor so it works without spring-boot-starter-parent. + injectLombokAnnotationProcessor(module, serviceRoot); + log.info(" ✓ Provisioned Lombok for {}", module.getServiceName()); + } + + if (needsValidation) { + String validationXml = """ + + org.springframework.boot + spring-boot-starter-validation + """; + injectDependency(module, serviceRoot, "spring-boot-starter-validation", validationXml); + log.info(" ✓ Provisioned spring-boot-starter-validation for {}", module.getServiceName()); + } + } + + /** + * Injects Lombok into the {@code } of {@code maven-compiler-plugin} + * so that Lombok annotation processing works when using {@code spring-boot-dependencies} as a + * BOM (rather than {@code spring-boot-starter-parent} which wires it automatically). + * + *

{@code annotationProcessorPaths} does NOT resolve versions from + * {@code }, so an explicit Lombok version is required. + * The version is derived from the {@code spring-boot.version} property in the pom. + */ + private void injectLombokAnnotationProcessor(FractalModule module, Path serviceRoot) { + try { + Path pomPath = serviceRoot.resolve("pom.xml"); + if (!Files.exists(pomPath)) return; + + String content = Files.readString(pomPath); + if (content.contains("annotationProcessorPaths")) return; // already configured + + int compilerIdx = content.indexOf("maven-compiler-plugin"); + if (compilerIdx == -1) return; + + // Find that closes the compiler plugin's block + int configEnd = content.indexOf("", compilerIdx); + if (configEnd == -1) return; + + String lombokVersion = resolveLombokVersion(content); + + String processorBlock = """ + + + + org.projectlombok + lombok + """ + lombokVersion + """ + + + """; + + String newContent = content.substring(0, configEnd) + + processorBlock + "\n " + + content.substring(configEnd); + Files.writeString(pomPath, newContent); + log.info("➕ [Dependency] Wired Lombok annotationProcessorPaths ({}) for {}", + lombokVersion, module.getServiceName()); + } catch (IOException e) { + log.error("Failed to add Lombok annotationProcessorPaths for {}", module.getServiceName(), e); + } + } + + /** + * Resolves the Lombok version corresponding to the Spring Boot version declared in the pom. + * Falls back to {@code 1.18.30} (Spring Boot 3.2.x) if the version cannot be determined. + */ + private String resolveLombokVersion(String pomContent) { + // Extract spring-boot.version property from the pom + String marker = ""; + int start = pomContent.indexOf(marker); + if (start != -1) { + int end = pomContent.indexOf("", start); + if (end != -1) { + String sbVersion = pomContent.substring(start + marker.length(), end).trim(); + if (sbVersion.startsWith("3.4")) return "1.18.36"; + if (sbVersion.startsWith("3.3")) return "1.18.32"; + if (sbVersion.startsWith("3.2")) return "1.18.30"; + if (sbVersion.startsWith("3.1")) return "1.18.28"; + if (sbVersion.startsWith("3.0")) return "1.18.24"; + } + } + return "1.18.30"; // safe default (Spring Boot 3.2.x) + } + private void injectDependency(FractalModule module, Path serviceRoot, String checkString, String rawXml) { try { Path pomPath = serviceRoot.resolve("pom.xml"); diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DistributedServiceHelper.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DistributedServiceHelper.java index f09489f..9285449 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DistributedServiceHelper.java +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/DistributedServiceHelper.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Set; /** * Central orchestrator for applying distributed systems capabilities to each @@ -62,7 +63,11 @@ public void upgradeService(FractalModule module, Path sourceRoot, Path serviceRo log.info("⚡ [Distributed] Applying distributed features to {}...", module.getServiceName()); // 1. Enforce data isolation (dual-package @EntityScan + @EnableJpaRepositories) - isolationGen.generateIsolationConfig(module, srcMainJava); + if (hasJpaContent(module)) { + isolationGen.generateIsolationConfig(module, srcMainJava); + } else { + log.info(" ⏭ No JPA entities in {} — skipping IsolationConfig", module.getServiceName()); + } // 2. Detect & apply database configuration (fractalx-config.yml → application.yml) String driverClass = dbConfigGen.generateDbConfig(module, sourceRoot, srcMainResources); @@ -82,8 +87,10 @@ public void upgradeService(FractalModule module, Path sourceRoot, Path serviceRo flywayGen.generateMigration(module, serviceRoot); // 5. Generate transactional outbox (for services with cross-module deps or sagas) - if (!module.getDependencies().isEmpty()) { + if (hasJpaContent(module) && !module.getDependencies().isEmpty()) { outboxGen.generateOutbox(module, serviceRoot, sagaDefinitions); + } else if (!module.getDependencies().isEmpty()) { + log.info(" ⏭ No JPA entities in {} — skipping Outbox (no DB to persist events)", module.getServiceName()); } // 6. Generate reference validators for decoupled foreign keys @@ -92,6 +99,20 @@ public void upgradeService(FractalModule module, Path sourceRoot, Path serviceRo // 7. Generate DATA_README.md dataReadmeGen.generateServiceDataReadme(module, serviceRoot, driverClass, sagaDefinitions); + // 8. Provision any implied dependencies detected in the fully-generated source + // (e.g. Lombok, jakarta.validation copied with model classes from other modules) + dependencyManager.provisionImpliedDependencies(module, serviceRoot); + log.info(" ✓ [Distributed] Upgrade complete for {}", module.getServiceName()); } + + private boolean hasJpaContent(FractalModule module) { + Set imports = module.getDetectedImports(); + if (imports == null || imports.isEmpty()) return false; + return imports.stream().anyMatch(i -> + i.startsWith("jakarta.persistence") || + i.startsWith("javax.persistence") || + i.startsWith("org.springframework.data.jpa") || + i.startsWith("org.springframework.data.repository")); + } } diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/FlywayMigrationGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/FlywayMigrationGenerator.java index 5cbe2a7..b66cc05 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/FlywayMigrationGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/FlywayMigrationGenerator.java @@ -3,8 +3,11 @@ import org.fractalx.core.model.FractalModule; import com.github.javaparser.JavaParser; import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.NodeList; import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.Type; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +16,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; /** @@ -101,9 +105,37 @@ private EntityInfo extractEntityInfo(ClassOrInterfaceDeclaration cls) { // Emit FK column: prefer @JoinColumn(name="..."), fall back to fieldName_id String colName = resolveJoinColumnName(field); info.fields.add(new ColumnInfo(colName, "BIGINT", false)); + } else if (hasAnnotation(field, "ManyToMany")) { + // Local @ManyToMany (same-service) → emit a join table. + // Remote @ManyToMany fields are decoupled to @ElementCollection by + // RelationshipDecoupler before this generator runs, so any remaining + // @ManyToMany here is a local relationship. + extractGenericTypeName(field).ifPresent(otherType -> { + String varName = field.getVariable(0).getNameAsString(); + String joinTable = info.tableName + "_" + toSnakeCase(varName); + String otherIdCol = toSnakeCase(otherType) + "_id"; + info.extraTableDdl.add( + "-- Join table for " + info.className + "." + varName + " (@ManyToMany)\n" + + "CREATE TABLE IF NOT EXISTS " + joinTable + " (\n" + + " " + info.tableName + "_id BIGINT,\n" + + " " + otherIdCol + " BIGINT,\n" + + " PRIMARY KEY (" + info.tableName + "_id, " + otherIdCol + ")\n" + + ");\n"); + }); + } else if (hasAnnotation(field, "ElementCollection")) { + // @ElementCollection List produced by RelationshipDecoupler for + // cross-service @ManyToMany → emit a dedicated element-collection table. + String varName = field.getVariable(0).getNameAsString(); + String colTable = info.tableName + "_" + toSnakeCase(varName); + info.extraTableDdl.add( + "-- Element collection table for " + info.className + "." + varName + + " (@ElementCollection, decoupled cross-service @ManyToMany)\n" + + "CREATE TABLE IF NOT EXISTS " + colTable + " (\n" + + " " + info.tableName + "_id BIGINT,\n" + + " " + toSnakeCase(varName) + " VARCHAR(255)\n" + + ");\n"); } else if (!hasAnnotation(field, "Transient") - && !hasAnnotation(field, "OneToMany") - && !hasAnnotation(field, "ManyToMany")) { + && !hasAnnotation(field, "OneToMany")) { info.fields.add(new ColumnInfo( toSnakeCase(field.getVariable(0).getNameAsString()), toSqlType(field.getElementType().asString()), @@ -158,6 +190,11 @@ private String buildMigrationScript(FractalModule module, List entit sb.append(String.join(",\n", columnDefs)); sb.append("\n);\n\n"); + + // Emit join tables / element-collection tables attached to this entity + for (String extraDdl : entity.extraTableDdl) { + sb.append(extraDdl).append("\n"); + } } // Outbox table — always generated for saga/event support @@ -216,6 +253,21 @@ private boolean hasAnnotation(com.github.javaparser.ast.nodeTypes.NodeWithAnnota return node.getAnnotations().stream().anyMatch(a -> a.getNameAsString().equals(name)); } + /** + * Extracts the first generic type argument from a field's variable type. + * E.g. {@code List} → {@code Optional.of("Course")}. + */ + private Optional extractGenericTypeName(FieldDeclaration field) { + if (field.getVariables().isEmpty()) return Optional.empty(); + Type type = field.getVariable(0).getType(); + if (!type.isClassOrInterfaceType()) return Optional.empty(); + ClassOrInterfaceType ct = type.asClassOrInterfaceType(); + if (ct.getTypeArguments().isEmpty()) return Optional.empty(); + NodeList args = ct.getTypeArguments().get(); + if (args.isEmpty() || !args.get(0).isClassOrInterfaceType()) return Optional.empty(); + return Optional.of(args.get(0).asClassOrInterfaceType().getNameAsString()); + } + private String toSnakeCase(String camelCase) { return camelCase.replaceAll("([A-Z])", "_$1").toLowerCase().replaceFirst("^_", ""); } @@ -242,7 +294,9 @@ private String toSqlType(String javaType) { private static class EntityInfo { String className; String tableName; - final List fields = new ArrayList<>(); + final List fields = new ArrayList<>(); + /** DDL for join tables (@ManyToMany) and element-collection tables (@ElementCollection). */ + final List extraTableDdl = new ArrayList<>(); } private static class ColumnInfo { diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ReferenceValidatorGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ReferenceValidatorGenerator.java index 4ad8a05..98fbcf7 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ReferenceValidatorGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ReferenceValidatorGenerator.java @@ -70,15 +70,18 @@ public void generateReferenceValidator(FractalModule module, Path serviceRoot) t buildReferenceValidator(validationPackage, basePackage, module, remoteIdTypes) ); - // Generate exists() stub for each remote type's NetScope client if not already present - for (String remoteType : remoteIdTypes) { - String clientName = remoteType + "ExistsClient"; - String targetService = NetScopeClientGenerator.beanTypeToServiceName(remoteType); - Path clientFile = validationPath.resolve(clientName + ".java"); + // Generate exists() stub for each remote type's NetScope client if not already present. + // Strip the "List:" prefix for collection types — the same exists(String id) interface + // handles both singular and collection validation (the validator iterates the list). + for (String rawType : remoteIdTypes) { + String bareType = rawType.startsWith("List:") ? rawType.substring(5) : rawType; + String clientName = bareType + "ExistsClient"; + String targetService = NetScopeClientGenerator.beanTypeToServiceName(bareType); + Path clientFile = validationPath.resolve(clientName + ".java"); if (!Files.exists(clientFile)) { Files.writeString(clientFile, buildExistsClient(validationPackage, clientName, - remoteType, targetService, module)); + bareType, targetService, module)); } } @@ -108,6 +111,26 @@ private Set detectDecoupledIdTypes(Path srcMainJava) { if (!hasAnnotation(cls, "Entity")) continue; for (FieldDeclaration field : cls.getFields()) { + + // Detect @ElementCollection List *Ids fields produced by + // RelationshipDecoupler for cross-service @ManyToMany relationships. + // Prefix "List:" marks the type as a collection so buildReferenceValidator() + // can generate the correct validateAll*Exist(List) method. + if (hasAnnotation(field, "ElementCollection")) { + field.getVariables().forEach(v -> { + String name = v.getNameAsString(); // e.g. "courseIds" + if (name.endsWith("Ids") && name.length() > 3) { + String base = name.substring(0, name.length() - 3); // "course" + if (!base.isEmpty()) { + String collectionType = Character.toUpperCase(base.charAt(0)) + + base.substring(1); // "Course" + types.add("List:" + collectionType); + } + } + }); + continue; // not a String field — skip the *Id check below + } + String typeName = field.getElementType().asString(); if (!"String".equals(typeName)) continue; @@ -117,8 +140,8 @@ private Set detectDecoupledIdTypes(Path srcMainJava) { if (name.endsWith("Id") && name.length() > 2) { String base = name.substring(0, name.length() - 2); if (!base.isEmpty()) { - String typeName2 = Character.toUpperCase(base.charAt(0)) + base.substring(1); - types.add(typeName2); + String singularType = Character.toUpperCase(base.charAt(0)) + base.substring(1); + types.add(singularType); } } }); @@ -141,17 +164,35 @@ private String buildReferenceValidator(String validationPackage, String basePackage, FractalModule module, Set remoteTypes) { + + // Split into singular types (String *Id) and collection types (List: prefix) + Set singleTypes = new LinkedHashSet<>(); + Set collectionTypes = new LinkedHashSet<>(); + for (String t : remoteTypes) { + if (t.startsWith("List:")) collectionTypes.add(t.substring(5)); + else singleTypes.add(t); + } + // All distinct bare type names (for client generation) + Set allBareTypes = new LinkedHashSet<>(); + allBareTypes.addAll(singleTypes); + allBareTypes.addAll(collectionTypes); + StringBuilder sb = new StringBuilder(); sb.append("package ").append(validationPackage).append(";\n\n"); - for (String type : remoteTypes) { + for (String type : allBareTypes) { sb.append("import ").append(validationPackage).append(".").append(type).append("ExistsClient;\n"); } - sb.append(""" - import jakarta.persistence.EntityNotFoundException; - import org.springframework.stereotype.Component; + if (!collectionTypes.isEmpty()) { + sb.append("import java.util.List;\n"); + } + sb.append("import org.springframework.stereotype.Component;\n\n"); - """); + // Javadoc — use first single type (or first collection type) as the example + String exampleType = singleTypes.isEmpty() + ? collectionTypes.iterator().next() + : singleTypes.iterator().next(); + String exampleParam = Character.toLowerCase(exampleType.charAt(0)) + exampleType.substring(1) + "Id"; sb.append("/**\n"); sb.append(" * Validates cross-service referential integrity using NetScope client calls.\n"); @@ -161,17 +202,16 @@ private String buildReferenceValidator(String validationPackage, sb.append(" *\n"); sb.append(" *

\n");
         sb.append(" * // Example usage in a @Transactional service method:\n");
-        sb.append(" * referenceValidator.validate").append(remoteTypes.iterator().next())
-          .append("Exists(").append(Character.toLowerCase(remoteTypes.iterator().next().charAt(0)))
-          .append(remoteTypes.iterator().next().substring(1)).append("Id);\n");
+        sb.append(" * referenceValidator.").append(ValidationNaming.singleValidateMethod(exampleType))
+          .append("(").append(exampleParam).append(");\n");
         sb.append(" * 
\n"); sb.append(" *\n * Auto-generated by FractalX — service: ").append(module.getServiceName()).append(".\n */\n"); sb.append("@Component\n"); sb.append("public class ReferenceValidator {\n\n"); - // Fields - for (String type : remoteTypes) { - String fieldName = Character.toLowerCase(type.charAt(0)) + type.substring(1) + "ExistsClient"; + // Fields — one ExistsClient per bare type + for (String type : allBareTypes) { + String fieldName = lc(type) + "ExistsClient"; sb.append(" private final ").append(type).append("ExistsClient ").append(fieldName).append(";\n"); } sb.append("\n"); @@ -179,43 +219,64 @@ private String buildReferenceValidator(String validationPackage, // Constructor sb.append(" public ReferenceValidator("); List ctorParams = new ArrayList<>(); - for (String type : remoteTypes) { - String clientType = type + "ExistsClient"; - String fieldName = Character.toLowerCase(type.charAt(0)) + type.substring(1) + "ExistsClient"; - ctorParams.add(clientType + " " + fieldName); + for (String type : allBareTypes) { + ctorParams.add(type + "ExistsClient " + lc(type) + "ExistsClient"); } sb.append(String.join(", ", ctorParams)).append(") {\n"); - for (String type : remoteTypes) { - String fieldName = Character.toLowerCase(type.charAt(0)) + type.substring(1) + "ExistsClient"; + for (String type : allBareTypes) { + String fieldName = lc(type) + "ExistsClient"; sb.append(" this.").append(fieldName).append(" = ").append(fieldName).append(";\n"); } sb.append(" }\n\n"); - // Validate methods - for (String type : remoteTypes) { - String clientField = Character.toLowerCase(type.charAt(0)) + type.substring(1) + "ExistsClient"; - String param = Character.toLowerCase(type.charAt(0)) + type.substring(1) + "Id"; + // Singular validate methods — names via ValidationNaming (shared with RelationshipDecoupler) + for (String type : singleTypes) { + String clientField = lc(type) + "ExistsClient"; + String param = lc(type) + "Id"; sb.append(" /**\n"); sb.append(" * Verifies that a ").append(type).append(" with the given ID exists in the remote service.\n"); - sb.append(" * @throws EntityNotFoundException if the ID cannot be resolved\n"); + sb.append(" * @throws IllegalArgumentException if the ID cannot be resolved\n"); sb.append(" */\n"); - sb.append(" public void validate").append(type).append("Exists(String ").append(param).append(") {\n"); + sb.append(" public void ").append(ValidationNaming.singleValidateMethod(type)) + .append("(String ").append(param).append(") {\n"); sb.append(" if (").append(param).append(" == null || ").append(param).append(".isBlank()) {\n"); - sb.append(" throw new EntityNotFoundException(\"").append(type) + sb.append(" throw new IllegalArgumentException(\"").append(type) .append(" ID must not be null or blank\");\n"); sb.append(" }\n"); - sb.append(" boolean exists = ").append(clientField).append(".exists(").append(param).append(");\n"); - sb.append(" if (!exists) {\n"); - sb.append(" throw new EntityNotFoundException(\"").append(type) + sb.append(" if (!").append(clientField).append(".exists(").append(param).append(")) {\n"); + sb.append(" throw new IllegalArgumentException(\"").append(type) .append(" not found: \" + ").append(param).append(");\n"); sb.append(" }\n"); sb.append(" }\n\n"); } + // Collection validate methods — names via ValidationNaming (shared with RelationshipDecoupler) + for (String type : collectionTypes) { + String param = lc(type) + "Ids"; + sb.append(" /**\n"); + sb.append(" * Verifies that every ").append(type).append(" ID in the list exists in the remote service.\n"); + sb.append(" * Delegates to {@link #").append(ValidationNaming.singleValidateMethod(type)).append("}.\n"); + sb.append(" * @throws IllegalArgumentException if any ID cannot be resolved\n"); + sb.append(" */\n"); + sb.append(" public void ").append(ValidationNaming.collectionValidateMethod(type)) + .append("(List ").append(param).append(") {\n"); + sb.append(" if (").append(param).append(" == null) return;\n"); + sb.append(" for (String id : ").append(param).append(") {\n"); + sb.append(" ").append(ValidationNaming.singleValidateMethod(type)).append("(id);\n"); + sb.append(" }\n"); + sb.append(" }\n\n"); + } + sb.append("}\n"); return sb.toString(); } + /** Lower-cases the first character of {@code s}. */ + private static String lc(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toLowerCase(s.charAt(0)) + s.substring(1); + } + private String buildExistsClient(String pkg, String clientName, String remoteType, String targetService, FractalModule module) { diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/RelationshipDecoupler.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/RelationshipDecoupler.java index 6db7b30..bcdd85c 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/RelationshipDecoupler.java +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/RelationshipDecoupler.java @@ -8,6 +8,7 @@ import com.github.javaparser.ast.body.*; import com.github.javaparser.ast.comments.LineComment; import com.github.javaparser.ast.expr.*; +import com.github.javaparser.ast.stmt.BlockStmt; import com.github.javaparser.ast.stmt.ExpressionStmt; import com.github.javaparser.ast.stmt.Statement; import com.github.javaparser.ast.type.ClassOrInterfaceType; @@ -26,8 +27,17 @@ /** * Performs AST-based decoupling of cross-service relationships. - * Converts JPA relationship fields to String ID fields and updates associated service logic. - * Handles both POJO and Record request DTOs. + * Converts JPA relationship fields to String / List<String> ID fields and updates + * associated service logic. Handles POJO, Record, and Lombok-annotated request DTOs. + * + *

Annotation handling

+ *
    + *
  • {@code @ManyToOne} / {@code @OneToOne} — singular remote entity → {@code String *Id}
  • + *
  • {@code @OneToMany} referencing a remote entity — collection removed entirely
  • + *
  • {@code @ManyToMany} referencing a remote entity — collection converted to + * {@code @ElementCollection List *Ids}
  • + *
  • Local relationships (both sides in the same module) — left untouched
  • + *
*/ public class RelationshipDecoupler { @@ -35,42 +45,50 @@ public class RelationshipDecoupler { private final JavaParser javaParser = new JavaParser(); - private static final Set REL_ANNOS = Set.of("ManyToOne", "OneToOne", "JoinColumn"); - private static final String ONE_TO_MANY = "OneToMany"; + private static final Set REL_ANNOS = Set.of("ManyToOne", "OneToOne", "JoinColumn"); + private static final String ONE_TO_MANY = "OneToMany"; + private static final String MANY_TO_MANY = "ManyToMany"; + + // ========================================================================= + // Public entry point + // ========================================================================= /** * Orchestrates the transformation process: identifies local/remote entities, - * indexes request DTOs, and applies AST modifications to source files. + * collects per-entity remote field info, indexes request DTOs, and applies AST + * modifications to all source files under {@code serviceRoot}. */ public void transform(Path serviceRoot, FractalModule module) { try { String modulePackage = module.getPackageName() != null ? module.getPackageName() : ""; - // Identify entities defined within this service's OWN package only. - // Using the module package as a filter prevents stale copied model classes - // (e.g. Payment.java / Product.java copied in a previous generation run) from - // being mistakenly classified as local, which would suppress decoupling. - Set localEntities = findLocalEntityNames(serviceRoot, modulePackage); - - // Identify entities referenced but not defined locally. - // Restrict the scan to the module's own files for the same reason. + Set localEntities = findLocalEntityNames(serviceRoot, modulePackage); Set remoteEntities = findRemoteEntities(serviceRoot, localEntities, modulePackage); if (remoteEntities.isEmpty()) { - log.info("✅ [Data] No remote entity references found for {}. Data is fully local.", module.getServiceName()); + log.info("✅ [Data] No remote entity references found for {}. Data is fully local.", + module.getServiceName()); return; } - log.info("🧩 [Data] Remote entity types to decouple for {}: {}", module.getServiceName(), remoteEntities); + log.info("🧩 [Data] Remote entity types to decouple for {}: {}", + module.getServiceName(), remoteEntities); + + // Pre-pass: build a map of entityClass → remote field info so that service + // validation injection can work without requiring cross-file state in the main walk. + Map entityRemoteIdFields = + collectEntityRemoteIdFields(serviceRoot, remoteEntities, modulePackage); + + // Build getter rename map for service-side chain detection (step 4) + Map collectionGetterRenames = buildCollectionGetterRenames(entityRemoteIdFields); - // Build index of Request objects to determine accessor style (Record vs Class) RequestInfoIndex requestIndex = buildRequestIndex(serviceRoot); - // Process all Java files try (Stream paths = Files.walk(serviceRoot)) { paths.filter(p -> p.toString().endsWith(".java")).forEach(path -> { try { - transformFile(path, localEntities, remoteEntities, requestIndex); + transformFile(path, localEntities, remoteEntities, requestIndex, + module, entityRemoteIdFields, collectionGetterRenames); } catch (Exception e) { log.error("❌ [Data] Failed to transform file: {}", path, e); } @@ -82,14 +100,141 @@ public void transform(Path serviceRoot, FractalModule module) { } } + // ========================================================================= + // Inner data holders + // ========================================================================= + + /** + * Remote-field information discovered for one entity class during the pre-pass. + * Exposed as package-private so tests can inspect it directly if needed. + */ + static class RemoteFieldInfo { + /** e.g. {@code "Payment" → "paymentId"} — singular {@code @ManyToOne} / {@code @OneToOne} field */ + final Map singleTypeToIdField = new LinkedHashMap<>(); + /** e.g. {@code "Course" → "courseIds"} — {@code @ManyToMany} decoupled to {@code @ElementCollection} */ + final Map collectionTypeToIdsField = new LinkedHashMap<>(); + /** Old field name → new field name for collection renames (e.g. {@code "courses" → "courseIds"}) */ + final Map collectionOldToNewField = new LinkedHashMap<>(); + + boolean isEmpty() { + return singleTypeToIdField.isEmpty() && collectionTypeToIdsField.isEmpty(); + } + } + + private static class RequestInfoIndex { + final Map requestInfo = new HashMap<>(); + } + + private static class RequestInfo { + final boolean isRecord; + final Set recordComponents = new HashSet<>(); + final Set pojoGetters = new HashSet<>(); + /** Getter name → declared return-type simple name (e.g. "getPrimaryTagId" → "Long"). */ + final Map getterTypes = new HashMap<>(); + + RequestInfo(boolean isRecord) { + this.isRecord = isRecord; + } + } + + // ========================================================================= + // Pre-pass: entity remote-field collection + // ========================================================================= + + /** + * Scans entity classes (filtered to {@code modulePackage}) and, for each, records + * which remote types map to which decoupled field names — both singular and collection. + * + *

This pre-pass is necessary because service files and entity files are separate; + * the main file walk transforms them independently, so cross-file knowledge must be + * gathered up front. + */ + private Map collectEntityRemoteIdFields( + Path root, Set remoteEntities, String modulePackage) { + + Map result = new HashMap<>(); + + try (Stream paths = Files.walk(root)) { + paths.filter(p -> p.toString().endsWith(".java")).forEach(path -> { + try { + Optional cuOpt = javaParser.parse(path).getResult(); + if (cuOpt.isEmpty()) return; + CompilationUnit cu = cuOpt.get(); + + if (!modulePackage.isBlank()) { + String filePkg = cu.getPackageDeclaration() + .map(pd -> pd.getNameAsString()).orElse(""); + if (!filePkg.startsWith(modulePackage)) return; + } + + for (ClassOrInterfaceDeclaration c : cu.findAll(ClassOrInterfaceDeclaration.class)) { + if (!hasAnnotation(c, "Entity")) continue; + + RemoteFieldInfo info = new RemoteFieldInfo(); + + for (FieldDeclaration field : c.getFields()) { + // Singular remote relationship (@ManyToOne / @OneToOne) + if (hasAnyAnnotation(field, REL_ANNOS)) { + for (VariableDeclarator var : field.getVariables()) { + String typeName = simpleTypeName(var.getType()); + if (typeName != null && remoteEntities.contains(typeName)) { + String old = var.getNameAsString(); + String newName = old.endsWith("Id") ? old : old + "Id"; + info.singleTypeToIdField.put(typeName, newName); + } + } + } + + // Collection remote relationship (@ManyToMany) + if (hasAnnotation(field, MANY_TO_MANY)) { + for (VariableDeclarator var : field.getVariables()) { + Optional generic = extractGenericTypeName(var.getType()); + if (generic.isPresent() && remoteEntities.contains(generic.get())) { + String old = var.getNameAsString(); + String newName = toIdsFieldName(old); + info.collectionTypeToIdsField.put(generic.get(), newName); + info.collectionOldToNewField.put(old, newName); + } + } + } + } + + if (!info.isEmpty()) { + result.put(c.getNameAsString(), info); + } + } + } catch (Exception ignored) {} + }); + } catch (IOException e) { + log.warn("Could not collect entity remote id fields: {}", e.getMessage()); + } + + return result; + } + + /** + * Builds a map of old getter name → new getter name for collection field renames + * across all entities. E.g. {@code "getCourses" → "getCourseIds"}. + */ + private Map buildCollectionGetterRenames(Map entityRemoteIdFields) { + Map renames = new HashMap<>(); + for (RemoteFieldInfo info : entityRemoteIdFields.values()) { + for (Map.Entry e : info.collectionOldToNewField.entrySet()) { + renames.put("get" + upperFirst(e.getKey()), "get" + upperFirst(e.getValue())); + } + } + return renames; + } + + // ========================================================================= + // Entity scanning + // ========================================================================= + /** * Scans source files to identify @Entity classes defined within {@code modulePackage}. * *

The package filter is critical when regenerating without a clean build: copied - * model classes from other modules (e.g. {@code Payment.java}, {@code Product.java}) - * may already be present in the service directory from a previous generation run. - * Without filtering they would be counted as local, causing remote relationships - * to go un-decoupled. + * model classes from other modules may already be present from a previous generation run. */ private Set findLocalEntityNames(Path root, String modulePackage) throws IOException { Set local = new HashSet<>(); @@ -101,11 +246,9 @@ private Set findLocalEntityNames(Path root, String modulePackage) throws if (cuOpt.isEmpty()) return; CompilationUnit cu = cuOpt.get(); - // Only count entities that belong to this module's package if (!modulePackage.isBlank()) { String filePkg = cu.getPackageDeclaration() - .map(pd -> pd.getNameAsString()) - .orElse(""); + .map(pd -> pd.getNameAsString()).orElse(""); if (!filePkg.startsWith(modulePackage)) return; } @@ -114,8 +257,7 @@ private Set findLocalEntityNames(Path root, String modulePackage) throws local.add(c.getNameAsString()); } } - } catch (Exception ignored) { - } + } catch (Exception ignored) {} }); } return local; @@ -123,8 +265,8 @@ private Set findLocalEntityNames(Path root, String modulePackage) throws /** * Identifies entity types used in relationships by module-owned entities that are - * not themselves defined locally. Only files within {@code modulePackage} are scanned - * to prevent stale copied model classes from introducing false positives. + * not themselves defined locally. Now handles {@code @ManyToMany} in addition to + * {@code @ManyToOne}, {@code @OneToOne}, and {@code @OneToMany}. */ private Set findRemoteEntities(Path root, Set localEntities, @@ -138,11 +280,9 @@ private Set findRemoteEntities(Path root, if (cuOpt.isEmpty()) return; CompilationUnit cu = cuOpt.get(); - // Restrict scan to files in the module's own package if (!modulePackage.isBlank()) { String filePkg = cu.getPackageDeclaration() - .map(pd -> pd.getNameAsString()) - .orElse(""); + .map(pd -> pd.getNameAsString()).orElse(""); if (!filePkg.startsWith(modulePackage)) return; } @@ -150,53 +290,48 @@ private Set findRemoteEntities(Path root, if (!hasAnnotation(c, "Entity")) continue; for (FieldDeclaration field : c.getFields()) { - if (!hasAnyAnnotation(field, REL_ANNOS) && !hasAnnotation(field, ONE_TO_MANY)) continue; - - // Collect types from @OneToMany List - if (hasAnnotation(field, ONE_TO_MANY)) { - field.getVariables().forEach(v -> { - Optional generic = extractGenericTypeName(v.getType()); - generic.ifPresent(typeName -> referenced.add(typeName)); - }); + boolean isRelAnno = hasAnyAnnotation(field, REL_ANNOS); + boolean isOneToMany = hasAnnotation(field, ONE_TO_MANY); + boolean isManyToMany = hasAnnotation(field, MANY_TO_MANY); + + if (!isRelAnno && !isOneToMany && !isManyToMany) continue; + + if (isOneToMany || isManyToMany) { + // Collection relationships: extract generic type (List → T) + field.getVariables().forEach(v -> + extractGenericTypeName(v.getType()).ifPresent(referenced::add)); continue; } - // Collect types from single relationships + // Singular relationships: direct type name field.getVariables().forEach(v -> { String typeName = simpleTypeName(v.getType()); if (typeName != null) referenced.add(typeName); }); } } - } catch (Exception ignored) { - } + } catch (Exception ignored) {} }); } - // Filter out local entities and standard Java types referenced.removeAll(localEntities); referenced.removeAll(Set.of("String", "Long", "Integer", "UUID", "Double", "Float", "Boolean")); return referenced; } - // Stores structure of Request objects to support correct code generation - private static class RequestInfoIndex { - final Map requestInfo = new HashMap<>(); - } - - private static class RequestInfo { - final boolean isRecord; - final Set recordComponents = new HashSet<>(); - final Set pojoGetters = new HashSet<>(); - - RequestInfo(boolean isRecord) { - this.isRecord = isRecord; - } - } + // ========================================================================= + // Request-index building (for service-side call-site rewriting) + // ========================================================================= /** * Builds an index of Request classes to determine if they are Records or POJOs * and capture available fields/getters. + * + *

Lombok {@code @Data} / {@code @Getter}: when the class carries one of these + * annotations there are no explicit {@code MethodDeclaration} nodes in the AST. + * The getter names are inferred from the declared field names so that + * {@link #tryBuildRequestIdAccessor} can still produce correct {@code request.getXxxId()} + * expressions on the call site. */ private RequestInfoIndex buildRequestIndex(Path serviceRoot) throws IOException { RequestInfoIndex idx = new RequestInfoIndex(); @@ -210,7 +345,7 @@ private RequestInfoIndex buildRequestIndex(Path serviceRoot) throws IOException if (cuOpt.isEmpty()) return; CompilationUnit cu = cuOpt.get(); - // Process Records + // Records for (RecordDeclaration rd : cu.findAll(RecordDeclaration.class)) { String name = rd.getNameAsString(); RequestInfo info = new RequestInfo(true); @@ -218,7 +353,7 @@ private RequestInfoIndex buildRequestIndex(Path serviceRoot) throws IOException idx.requestInfo.put(name, info); } - // Process POJO Classes + // POJO classes for (ClassOrInterfaceDeclaration cd : cu.findAll(ClassOrInterfaceDeclaration.class)) { if (cd.isInterface()) continue; if (!cd.getNameAsString().endsWith("Request")) continue; @@ -227,29 +362,51 @@ private RequestInfoIndex buildRequestIndex(Path serviceRoot) throws IOException String name = cd.getNameAsString(); RequestInfo info = new RequestInfo(false); + // Explicit getter methods for (MethodDeclaration m : cd.getMethods()) { if (m.getNameAsString().startsWith("get")) { info.pojoGetters.add(m.getNameAsString()); + String retType = simpleTypeName(m.getType()); + if (retType != null) info.getterTypes.put(m.getNameAsString(), retType); } } + + // Lombok @Data / @Getter: infer getter names from field declarations + if (hasAnnotation(cd, "Data") || hasAnnotation(cd, "Getter")) { + for (FieldDeclaration fd : cd.getFields()) { + String typeName = simpleTypeName(fd.getElementType()); + fd.getVariables().forEach(v -> { + String getter = "get" + upperFirst(v.getNameAsString()); + info.pojoGetters.add(getter); + if (typeName != null) info.getterTypes.put(getter, typeName); + }); + } + } + idx.requestInfo.put(name, info); } - } catch (Exception ignored) { - } + } catch (Exception ignored) {} }); } return idx; } + // ========================================================================= + // File-level transformation + // ========================================================================= + /** - * Applies AST transformations to a single Java file: removes imports, - * transforms entities, and updates service logic. + * Applies AST transformations to a single Java file: entity decoupling, service + * logic rewriting, validator injection, and import cleanup. */ private void transformFile(Path javaFile, Set localEntities, Set remoteEntities, - RequestInfoIndex requestIndex) throws IOException { + RequestInfoIndex requestIndex, + FractalModule module, + Map entityRemoteIdFields, + Map collectionGetterRenames) throws IOException { Optional cuOpt = javaParser.parse(javaFile).getResult(); if (cuOpt.isEmpty()) return; @@ -257,17 +414,21 @@ private void transformFile(Path javaFile, CompilationUnit cu = cuOpt.get(); boolean modified = false; + // Track which field names in this file were converted from @ManyToMany collections + // so that updateEntityAccessors() knows to use List instead of String. + Set collectionIdFields = new HashSet<>(); + for (ClassOrInterfaceDeclaration c : cu.findAll(ClassOrInterfaceDeclaration.class)) { if (hasAnnotation(c, "Entity")) { - modified |= transformEntityClass(c, remoteEntities); + modified |= transformEntityClass(c, remoteEntities, collectionIdFields); } } - modified |= transformServiceLogic(cu, remoteEntities, requestIndex); + modified |= transformServiceLogic(cu, remoteEntities, requestIndex, collectionGetterRenames); + modified |= injectReferenceValidatorUsage(cu, entityRemoteIdFields, module); - // Remove imports for remote entity types only when they are no longer referenced - // after all transformations. This preserves imports for entity objects that remain - // as service-call results (e.g. Payment payment = paymentServiceClient.processPayment(...)). + // Remove imports for remote entity types that are no longer referenced. + // Must run last so the check reflects the final AST state. modified |= removeRemoteImports(cu, remoteEntities); if (modified) { @@ -276,25 +437,42 @@ private void transformFile(Path javaFile, } } + // ========================================================================= + // Entity class transformation + // ========================================================================= + /** * Modifies entity classes: converts relationship fields to ID fields and updates accessors. + * + * @param collectionIdFields out-parameter populated with OLD field names whose type was + * widened to {@code List} (i.e. former {@code @ManyToMany} + * fields). Used by {@link #updateEntityAccessors} to choose the + * correct accessor return / parameter type. */ - private boolean transformEntityClass(ClassOrInterfaceDeclaration entityClass, Set remoteEntities) { + private boolean transformEntityClass(ClassOrInterfaceDeclaration entityClass, + Set remoteEntities, + Set collectionIdFields) { boolean modified = false; Map fieldRenameMap = new HashMap<>(); for (FieldDeclaration field : new ArrayList<>(entityClass.getFields())) { - // Remove OneToMany lists referencing remote entities + // Remove @OneToMany collections referencing remote entities if (hasAnnotation(field, ONE_TO_MANY)) { - boolean removedAny = removeRemoteOneToManyCollections(entityClass, field, remoteEntities); - modified |= removedAny; + modified |= removeRemoteOneToManyCollections(entityClass, field, remoteEntities); + continue; + } + + // Convert @ManyToMany List → @ElementCollection List *Ids + if (hasAnnotation(field, MANY_TO_MANY)) { + modified |= convertRemoteManyToManyCollection( + entityClass, field, remoteEntities, fieldRenameMap, collectionIdFields); continue; } if (!hasAnyAnnotation(field, REL_ANNOS)) continue; - // Convert entity references to String ID fields + // Convert singular entity references to String ID fields for (VariableDeclarator var : field.getVariables()) { String typeName = simpleTypeName(var.getType()); if (typeName == null || !remoteEntities.contains(typeName)) continue; @@ -317,7 +495,7 @@ private boolean transformEntityClass(ClassOrInterfaceDeclaration entityClass, Se } if (!fieldRenameMap.isEmpty()) { - modified |= updateEntityAccessors(entityClass, fieldRenameMap); + modified |= updateEntityAccessors(entityClass, fieldRenameMap, collectionIdFields); modified |= renameFieldReferences(entityClass, fieldRenameMap); } @@ -325,7 +503,7 @@ private boolean transformEntityClass(ClassOrInterfaceDeclaration entityClass, Se } /** - * Removes list fields annotated with @OneToMany if they reference a remote entity. + * Removes list fields annotated with {@code @OneToMany} if they reference a remote entity. */ private boolean removeRemoteOneToManyCollections(ClassOrInterfaceDeclaration entityClass, FieldDeclaration field, @@ -336,47 +514,111 @@ private boolean removeRemoteOneToManyCollections(ClassOrInterfaceDeclaration ent Optional generic = extractGenericTypeName(var.getType()); if (generic.isPresent() && remoteEntities.contains(generic.get())) { field.remove(); - entityClass.addOrphanComment(new LineComment(" Removed remote relationship list: " + generic.get())); + entityClass.addOrphanComment( + new LineComment(" Removed remote relationship list: " + generic.get())); modified = true; } } return modified; } + /** + * Converts a remote {@code @ManyToMany List courses} field to + * {@code @ElementCollection List courseIds}. + * + *

Side effects: + *

    + *
  • Adds old field name to {@code fieldRenameMap} and {@code collectionIdFields}
  • + *
  • Adds {@code jakarta.persistence.ElementCollection} import to the CU
  • + *
  • Adds {@code java.util.List} import to the CU (if not already present)
  • + *
+ */ + private boolean convertRemoteManyToManyCollection(ClassOrInterfaceDeclaration entityClass, + FieldDeclaration field, + Set remoteEntities, + Map fieldRenameMap, + Set collectionIdFields) { + boolean modified = false; + + for (VariableDeclarator var : field.getVariables()) { + Optional generic = extractGenericTypeName(var.getType()); + if (generic.isEmpty() || !remoteEntities.contains(generic.get())) continue; + + String oldFieldName = var.getNameAsString(); // e.g. "courses" + String newFieldName = toIdsFieldName(oldFieldName); // e.g. "courseIds" + + // Change List → List + var.setType(listOfString()); + + if (!oldFieldName.equals(newFieldName)) { + var.setName(newFieldName); + fieldRenameMap.put(oldFieldName, newFieldName); + collectionIdFields.add(oldFieldName); + } + + // Remove @ManyToMany / @JoinTable; replace with @ElementCollection + removeAnnotationsByName(field, Set.of(MANY_TO_MANY, "JoinTable")); + field.addAnnotation(new MarkerAnnotationExpr("ElementCollection")); + + // Ensure the required imports exist + entityClass.findCompilationUnit().ifPresent(cu -> { + ensureImport(cu, "jakarta.persistence.ElementCollection"); + ensureImport(cu, "java.util.List"); + }); + + entityClass.addOrphanComment(new LineComment( + " Decoupled cross-service ManyToMany: " + generic.get() + + " replaced by ElementCollection " + newFieldName)); + modified = true; + } + return modified; + } + /** * Updates getter and setter signatures and bodies to match renamed ID fields. + * + * @param collectionIdFields set of OLD field names that were widened to {@code List} + * (former {@code @ManyToMany} fields). These get + * {@code List} as their accessor type instead of + * {@code String}. */ - private boolean updateEntityAccessors(ClassOrInterfaceDeclaration entityClass, Map renameMap) { + private boolean updateEntityAccessors(ClassOrInterfaceDeclaration entityClass, + Map renameMap, + Set collectionIdFields) { boolean modified = false; for (MethodDeclaration m : entityClass.getMethods()) { - // Update Getter + + // --- Getter --- if (m.getParameters().isEmpty() && m.getNameAsString().startsWith("get")) { String suffix = m.getNameAsString().substring(3); if (suffix.isEmpty()) continue; String guessedField = lowerFirst(suffix); if (renameMap.containsKey(guessedField)) { - String newField = renameMap.get(guessedField); - m.setType("String"); + String newField = renameMap.get(guessedField); + boolean isList = collectionIdFields.contains(guessedField); + + m.setType(isList ? listOfString() : new ClassOrInterfaceType(null, "String")); m.setName("get" + upperFirst(newField)); - m.findAll(ReturnStmt.class).forEach(r -> { - r.getExpression().ifPresent(expr -> { - if (expr.isNameExpr() && expr.asNameExpr().getNameAsString().equals(guessedField)) { - r.setExpression(new NameExpr(newField)); - } - if (expr.isFieldAccessExpr() && expr.asFieldAccessExpr().getNameAsString().equals(guessedField)) { - expr.asFieldAccessExpr().setName(newField); - } - }); - }); + m.findAll(ReturnStmt.class).forEach(r -> + r.getExpression().ifPresent(expr -> { + if (expr.isNameExpr() + && expr.asNameExpr().getNameAsString().equals(guessedField)) { + r.setExpression(new NameExpr(newField)); + } + if (expr.isFieldAccessExpr() + && expr.asFieldAccessExpr().getNameAsString().equals(guessedField)) { + expr.asFieldAccessExpr().setName(newField); + } + })); modified = true; } } - // Update Setter + // --- Setter --- if (m.getNameAsString().startsWith("set") && m.getParameters().size() == 1) { String suffix = m.getNameAsString().substring(3); if (suffix.isEmpty()) continue; @@ -384,26 +626,25 @@ private boolean updateEntityAccessors(ClassOrInterfaceDeclaration entityClass, M String guessedField = lowerFirst(suffix); if (renameMap.containsKey(guessedField)) { String newField = renameMap.get(guessedField); + boolean isList = collectionIdFields.contains(guessedField); m.setName("set" + upperFirst(newField)); - m.getParameter(0).setType("String"); + m.getParameter(0).setType(isList ? listOfString() : new ClassOrInterfaceType(null, "String")); m.getParameter(0).setName(newField); m.findAll(AssignExpr.class).forEach(a -> { Expression target = a.getTarget(); if (target.isFieldAccessExpr()) { FieldAccessExpr fa = target.asFieldAccessExpr(); - if (fa.getNameAsString().equals(guessedField)) { - fa.setName(newField); - } - } else if (target.isNameExpr()) { - if (target.asNameExpr().getNameAsString().equals(guessedField)) { - a.setTarget(new NameExpr(newField)); - } + if (fa.getNameAsString().equals(guessedField)) fa.setName(newField); + } else if (target.isNameExpr() + && target.asNameExpr().getNameAsString().equals(guessedField)) { + a.setTarget(new NameExpr(newField)); } Expression value = a.getValue(); - if (value.isNameExpr() && value.asNameExpr().getNameAsString().equals(guessedField)) { + if (value.isNameExpr() + && value.asNameExpr().getNameAsString().equals(guessedField)) { a.setValue(new NameExpr(newField)); } }); @@ -417,9 +658,10 @@ private boolean updateEntityAccessors(ClassOrInterfaceDeclaration entityClass, M } /** - * Updates internal field references (this.field -> this.fieldId). + * Updates internal field references ({@code this.field → this.fieldId}). */ - private boolean renameFieldReferences(ClassOrInterfaceDeclaration entityClass, Map renameMap) { + private boolean renameFieldReferences(ClassOrInterfaceDeclaration entityClass, + Map renameMap) { boolean modified = false; for (NameExpr ne : entityClass.findAll(NameExpr.class)) { @@ -441,33 +683,44 @@ private boolean renameFieldReferences(ClassOrInterfaceDeclaration entityClass, M return modified; } + // ========================================================================= + // Service logic transformation + // ========================================================================= + /** * Updates service logic to handle ID fields instead of object references. - * Handles variable instantiation, setId calls, and setEntity calls. + * Handles variable instantiation, setId calls, setter calls, old collection + * getter renames, and chained access warnings. + * + * @param collectionGetterRenames map of {@code oldGetterName → newGetterName} derived from + * the entity pre-pass (e.g. {@code "getCourses" → "getCourseIds"}) */ private boolean transformServiceLogic(CompilationUnit cu, Set remoteEntities, - RequestInfoIndex requestIndex) { + RequestInfoIndex requestIndex, + Map collectionGetterRenames) { boolean modified = false; for (MethodDeclaration method : cu.findAll(MethodDeclaration.class)) { + // Step 0: Remove method parameters whose type is a cross-module entity. + // e.g. createBudget(Request r, Tag primaryTag, List tags) + // → createBudget(Request r) (Tag params stripped, body fixed) + modified |= stripRemoteEntityMethodParams(method, remoteEntities); + Set localNames = new HashSet<>(); Map remoteVarToIdVar = new HashMap<>(); - // Vars that hold remote entities returned from service calls — keep as objects, - // but setter calls on them need to be rewritten to use .getId() + // Vars holding remote entities returned from service calls — kept as objects Set remoteEntityServiceVars = new HashSet<>(); method.getParameters().forEach(p -> localNames.add(p.getNameAsString())); - // 1) Update local variable declarations (Entity e -> String eId) - // Skip vars initialized from method calls — those are service call results - // that should remain as entity objects, not be demoted to String IDs. + // Step 1: Update local variable declarations (Entity e → String eId) + // Skip vars initialised from method calls — those are service-call results. for (VariableDeclarator vd : method.findAll(VariableDeclarator.class)) { String typeName = simpleTypeName(vd.getType()); if (typeName == null || !remoteEntities.contains(typeName)) continue; if (vd.getInitializer().isPresent() && vd.getInitializer().get().isMethodCallExpr()) { - // e.g. Payment payment = paymentService.processPayment(...) — keep as entity remoteEntityServiceVars.add(vd.getNameAsString()); continue; } @@ -488,11 +741,10 @@ private boolean transformServiceLogic(CompilationUnit cu, remoteVarToIdVar.put(oldName, newName); localNames.add(newName); - modified = true; } - // 2) Update setId calls (e.setId(x) -> eId = x) + // Step 2: Update setId calls (e.setId(x) → eId = x) for (MethodCallExpr call : method.findAll(MethodCallExpr.class)) { if (call.getScope().isEmpty()) continue; if (!call.getNameAsString().equals("setId")) continue; @@ -505,7 +757,8 @@ private boolean transformServiceLogic(CompilationUnit cu, if (!remoteVarToIdVar.containsKey(varName)) continue; String idVar = remoteVarToIdVar.get(varName); - AssignExpr assign = new AssignExpr(new NameExpr(idVar), call.getArgument(0), AssignExpr.Operator.ASSIGN); + AssignExpr assign = new AssignExpr( + new NameExpr(idVar), call.getArgument(0), AssignExpr.Operator.ASSIGN); Optional stmtOpt = call.findAncestor(Statement.class); if (stmtOpt.isPresent() && stmtOpt.get().isExpressionStmt()) { @@ -514,7 +767,7 @@ private boolean transformServiceLogic(CompilationUnit cu, } } - // 3) Update setter calls (setEntity(e) -> setEntityId(eId) or setEntityId(request.getId())) + // Step 3: Update setter calls (setEntity(e) → setEntityId(eId) / setEntityId(req.getId())) for (MethodCallExpr call : method.findAll(MethodCallExpr.class)) { if (!call.getNameAsString().startsWith("set")) continue; if (call.getArguments().size() != 1) continue; @@ -527,18 +780,17 @@ private boolean transformServiceLogic(CompilationUnit cu, // Case A: Variable was renamed locally if (remoteVarToIdVar.containsKey(argName)) { String idVar = remoteVarToIdVar.get(argName); - if (!call.getNameAsString().endsWith("Id")) { call.setName(call.getNameAsString() + "Id"); } - call.setArgument(0, new NameExpr(idVar)); modified = true; continue; } // Case B: Try to resolve ID from Request object - Optional requestAccessor = tryBuildRequestIdAccessor(method, call.getNameAsString(), requestIndex); + Optional requestAccessor = + tryBuildRequestIdAccessor(method, call.getNameAsString(), requestIndex); if (requestAccessor.isPresent()) { if (!call.getNameAsString().endsWith("Id")) { call.setName(call.getNameAsString() + "Id"); @@ -549,9 +801,7 @@ private boolean transformServiceLogic(CompilationUnit cu, } // Case C: Variable holds a remote entity returned from a service call. - // Rename setter to *Id and convert the entity's Long @Id to String. - // e.g. order.setPayment(payment) → order.setPaymentId(String.valueOf(payment.getId())) - // String.valueOf() is used (vs .toString()) to avoid NPE on null IDs. + // Rename setter to *Id and convert .getId() to String. if (remoteEntityServiceVars.contains(argName)) { if (!call.getNameAsString().endsWith("Id")) { call.setName(call.getNameAsString() + "Id"); @@ -562,19 +812,316 @@ private boolean transformServiceLogic(CompilationUnit cu, modified = true; } } + + // Step 4: Rename old collection getters (getCourses → getCourseIds) and warn + // on any complex chain that accesses entity properties via the renamed getter. + if (!collectionGetterRenames.isEmpty()) { + final boolean[] step4Modified = {false}; + + for (MethodCallExpr call : new ArrayList<>(method.findAll(MethodCallExpr.class))) { + String callName = call.getNameAsString(); + if (!collectionGetterRenames.containsKey(callName)) continue; + + String newGetterName = collectionGetterRenames.get(callName); + call.setName(newGetterName); + step4Modified[0] = true; + + // If this renamed call is the scope of an outer chained call, the chain + // now accesses List items rather than entity objects → warn developer. + call.getParentNode().ifPresent(parent -> { + if (parent instanceof MethodCallExpr outerCall + && outerCall.getScope().map(s -> s == call).orElse(false)) { + call.findAncestor(Statement.class).ifPresent(stmt -> { + if (stmt.getComment().isEmpty()) { + stmt.setComment(new LineComment( + " [FractalX] DECOUPLING WARNING: " + newGetterName + + "() returns List." + + " This chain accesses remote entity properties" + + " that no longer exist here." + + " Rewrite using a remote service lookup.")); + } + }); + } + }); + } + + modified |= step4Modified[0]; + } + } + + return modified; + } + + // ========================================================================= + // Remote entity method-parameter stripping + // ========================================================================= + + /** + * Removes method parameters whose declared type (or generic type argument) is a + * cross-module entity type, then fixes any body statements that still reference the + * removed parameter names. + * + *

Example: + *

+     *   createBudgetWithTag(Request r, Tag primaryTag, List<Tag> tags)
+     *   →  createBudgetWithTag(Request r)
+     *   budget.setTags(tags) → budget.setTagIds(new java.util.ArrayList<>())
+     * 
+ */ + private boolean stripRemoteEntityMethodParams(MethodDeclaration method, + Set remoteEntities) { + // Collect params whose base or generic type is a remote entity + Set removedNames = new LinkedHashSet<>(); + Map isListParam = new HashMap<>(); + + for (Parameter param : new ArrayList<>(method.getParameters())) { + String base = simpleTypeName(param.getType()); + boolean directMatch = base != null && remoteEntities.contains(base); + boolean genericMatch = !directMatch + && extractGenericTypeName(param.getType()) + .map(remoteEntities::contains).orElse(false); + if (directMatch || genericMatch) { + removedNames.add(param.getNameAsString()); + isListParam.put(param.getNameAsString(), genericMatch); + method.getParameters().remove(param); + } + } + + if (removedNames.isEmpty()) return false; + if (method.getBody().isEmpty()) return true; + + BlockStmt body = method.getBody().get(); + + for (Statement stmt : new ArrayList<>(body.getStatements())) { + boolean refsRemovedParam = stmt.findAll(NameExpr.class).stream() + .map(NameExpr::getNameAsString) + .anyMatch(removedNames::contains); + if (!refsRemovedParam) continue; + + if (!stmt.isExpressionStmt()) { + body.getStatements().remove(stmt); + continue; + } + + Expression expr = stmt.asExpressionStmt().getExpression(); + if (!expr.isMethodCallExpr()) { + body.getStatements().remove(stmt); + continue; + } + + MethodCallExpr call = expr.asMethodCallExpr(); + String callName = call.getNameAsString(); + + // Setter call with removed param as argument + if (callName.startsWith("set") && call.getArguments().size() == 1 + && call.getArgument(0).isNameExpr() + && removedNames.contains(call.getArgument(0).asNameExpr().getNameAsString())) { + String paramName = call.getArgument(0).asNameExpr().getNameAsString(); + if (isListParam.getOrDefault(paramName, false)) { + // List param: setFoo(listParam) → setFooIds(new ArrayList<>()) + // Use entity naming convention to derive the correct id-list setter name. + String suffix = callName.substring(3); // "Tags" + String idFieldName = toIdsFieldName(lowerFirst(suffix)); // "tagIds" + call.setName("set" + upperFirst(idFieldName)); // "setTagIds" + call.setArgument(0, new ObjectCreationExpr(null, + new ClassOrInterfaceType(null, "java.util.ArrayList"), + new NodeList<>())); + } + // Singular entity param: leave the statement so Step 3 Case B can rewrite + // setFoo(param) → setFooId(request.getFooId()). + continue; + } + + // Log call: drop the removed-param arguments (dangling {} in format string is harmless) + if (callName.equals("info") || callName.equals("debug") + || callName.equals("warn") || callName.equals("error")) { + for (int i = call.getArguments().size() - 1; i >= 1; i--) { + Expression arg = call.getArgument(i); + if (arg.isNameExpr() + && removedNames.contains(arg.asNameExpr().getNameAsString())) { + call.getArguments().remove(i); + } + } + continue; + } + + // Any other statement referencing a removed param — remove it + body.getStatements().remove(stmt); + } + + return true; + } + + // ReferenceValidator injection into @Service classes + // ========================================================================= + + /** + * Injects a {@code ReferenceValidator} field, constructor parameter, and pre-save + * validation calls into any {@code @Service} class found in {@code cu}. + * + *

Skips injection when {@code entityRemoteIdFields} is empty (no remote relationships + * exist) or when the class already carries a {@code referenceValidator} field (idempotent). + */ + private boolean injectReferenceValidatorUsage(CompilationUnit cu, + Map entityRemoteIdFields, + FractalModule module) { + if (entityRemoteIdFields.isEmpty() || module.getDependencies().isEmpty()) return false; + + boolean modified = false; + + String validationPackage = "org.fractalx.generated." + + module.getServiceName().replace("-", "") + ".validation"; + + for (ClassOrInterfaceDeclaration svcClass : cu.findAll(ClassOrInterfaceDeclaration.class)) { + if (svcClass.isInterface()) continue; + if (!hasAnnotation(svcClass, "Service")) continue; + + // Idempotency guard + boolean alreadyInjected = svcClass.getFields().stream() + .anyMatch(f -> f.getElementType().asString().equals("ReferenceValidator")); + if (alreadyInjected) continue; + + // 1. Add private final field + svcClass.addField("ReferenceValidator", "referenceValidator", + Modifier.Keyword.PRIVATE, Modifier.Keyword.FINAL); + + // 2. Import + ensureImport(cu, validationPackage + ".ReferenceValidator"); + + // 3. Update constructor (or create one if none exist and no Lombok ctor annotation) + List ctors = svcClass.getConstructors(); + if (!ctors.isEmpty()) { + ConstructorDeclaration ctor = ctors.get(0); + ctor.addParameter(new Parameter( + new ClassOrInterfaceType(null, "ReferenceValidator"), "referenceValidator")); + ctor.getBody().addStatement(new ExpressionStmt(new AssignExpr( + new FieldAccessExpr(new ThisExpr(), "referenceValidator"), + new NameExpr("referenceValidator"), + AssignExpr.Operator.ASSIGN))); + } else if (!hasAnnotation(svcClass, "RequiredArgsConstructor") + && !hasAnnotation(svcClass, "AllArgsConstructor")) { + // No constructor and no Lombok — generate a minimal one + ConstructorDeclaration ctor = svcClass.addConstructor(Modifier.Keyword.PUBLIC); + ctor.addParameter(new Parameter( + new ClassOrInterfaceType(null, "ReferenceValidator"), "referenceValidator")); + ctor.getBody().addStatement(new ExpressionStmt(new AssignExpr( + new FieldAccessExpr(new ThisExpr(), "referenceValidator"), + new NameExpr("referenceValidator"), + AssignExpr.Operator.ASSIGN))); + } + // If @RequiredArgsConstructor / @AllArgsConstructor: the added final field is enough. + + // 4. Inject validation calls before repository.save() in service methods + for (MethodDeclaration method : svcClass.getMethods()) { + modified |= injectValidationBeforeSave(method, entityRemoteIdFields); + } + + modified = true; + } + + return modified; + } + + /** + * For each {@code repository.save(entityVar)} call in {@code method}, inserts + * {@code referenceValidator.validateXExists(entityVar.getXId())} statements + * (and the collection variant) immediately before the save statement. + * + *

Uses {@link ValidationNaming} to derive method names so they are guaranteed to + * match what {@link ReferenceValidatorGenerator} generates in the validator bean. + */ + private boolean injectValidationBeforeSave(MethodDeclaration method, + Map entityRemoteIdFields) { + boolean modified = false; + + for (MethodCallExpr saveCall : new ArrayList<>(method.findAll(MethodCallExpr.class))) { + if (!saveCall.getNameAsString().equals("save")) continue; + if (saveCall.getArguments().size() != 1) continue; + + Expression arg = saveCall.getArgument(0); + if (!arg.isNameExpr()) continue; + + String entityVarName = arg.asNameExpr().getNameAsString(); + + // Resolve the declared type of the entity variable within this method scope + String entityType = null; + for (VariableDeclarator vd : method.findAll(VariableDeclarator.class)) { + if (vd.getNameAsString().equals(entityVarName)) { + entityType = simpleTypeName(vd.getType()); + break; + } + } + if (entityType == null) continue; + + RemoteFieldInfo info = entityRemoteIdFields.get(entityType); + if (info == null || info.isEmpty()) continue; + + Optional stmtOpt = saveCall.findAncestor(Statement.class); + if (stmtOpt.isEmpty()) continue; + Statement saveStmt = stmtOpt.get(); + + Optional blockOpt = saveStmt.findAncestor(BlockStmt.class); + if (blockOpt.isEmpty()) continue; + BlockStmt block = blockOpt.get(); + + NodeList stmts = block.getStatements(); + int saveIdx = -1; + for (int i = 0; i < stmts.size(); i++) { + if (stmts.get(i) == saveStmt) { saveIdx = i; break; } + } + if (saveIdx < 0) continue; + + List validateStmts = new ArrayList<>(); + + // Singular: referenceValidator.validatePaymentExists(entity.getPaymentId()) + for (Map.Entry entry : info.singleTypeToIdField.entrySet()) { + String type = entry.getKey(); // "Payment" + String idField = entry.getValue(); // "paymentId" + MethodCallExpr getter = new MethodCallExpr( + new NameExpr(entityVarName), "get" + upperFirst(idField)); + MethodCallExpr validate = new MethodCallExpr( + new NameExpr("referenceValidator"), + ValidationNaming.singleValidateMethod(type), // shared utility + new NodeList<>(getter)); + validateStmts.add(new ExpressionStmt(validate)); + } + + // Collection: referenceValidator.validateAllCourseExist(entity.getCourseIds()) + for (Map.Entry entry : info.collectionTypeToIdsField.entrySet()) { + String type = entry.getKey(); // "Course" + String idsField = entry.getValue(); // "courseIds" + MethodCallExpr getter = new MethodCallExpr( + new NameExpr(entityVarName), "get" + upperFirst(idsField)); + MethodCallExpr validate = new MethodCallExpr( + new NameExpr("referenceValidator"), + ValidationNaming.collectionValidateMethod(type), // shared utility + new NodeList<>(getter)); + validateStmts.add(new ExpressionStmt(validate)); + } + + for (int i = 0; i < validateStmts.size(); i++) { + stmts.add(saveIdx + i, validateStmts.get(i)); + } + + if (!validateStmts.isEmpty()) modified = true; } return modified; } + // ========================================================================= + // Request accessor helper + // ========================================================================= + /** - * Attempts to build an ID accessor (e.g. request.getCustomerId()) based on the setter name and request type. + * Attempts to build an ID accessor (e.g. {@code request.getCustomerId()}) based on + * the setter name and request type. */ private Optional tryBuildRequestIdAccessor(MethodDeclaration method, - String setterName, - RequestInfoIndex requestIndex) { + String setterName, + RequestInfoIndex requestIndex) { if (!setterName.startsWith("set") || setterName.length() <= 3) return Optional.empty(); - String base = lowerFirst(setterName.substring(3)); + String base = lowerFirst(setterName.substring(3)); String idName = base.endsWith("Id") ? base : base + "Id"; Optional requestParamOpt = method.getParameters().stream() @@ -582,8 +1129,7 @@ private Optional tryBuildRequestIdAccessor(MethodDeclaration method, .findFirst(); if (requestParamOpt.isEmpty()) return Optional.empty(); - Parameter requestParam = requestParamOpt.get(); - String requestType = simpleTypeName(requestParam.getType()); + String requestType = simpleTypeName(requestParamOpt.get().getType()); if (requestType == null) return Optional.empty(); RequestInfo info = requestIndex.requestInfo.get(requestType); @@ -596,28 +1142,36 @@ private Optional tryBuildRequestIdAccessor(MethodDeclaration method, } else { String getter = "get" + upperFirst(idName); if (info.pojoGetters.contains(getter)) { - return Optional.of(new MethodCallExpr(new NameExpr("request"), getter)); + MethodCallExpr getterCall = new MethodCallExpr(new NameExpr("request"), getter); + // If the DTO field type is not String, the converted entity ID field (String) would + // cause a type mismatch. Wrap with String.valueOf() to coerce safely. + String fieldType = info.getterTypes.get(getter); + if (fieldType != null && !fieldType.equals("String")) { + return Optional.of(new MethodCallExpr(new NameExpr("String"), "valueOf", + new NodeList<>(getterCall))); + } + return Optional.of(getterCall); } } return Optional.empty(); } + // ========================================================================= + // Import cleanup + // ========================================================================= + /** * Removes imports for remote entity types that are no longer referenced in the file. * - *

This must run after all AST transformations so that the reference check reflects - * the final state of the file. Types that are still used (e.g. a {@code Payment} - * variable returned from a service call) retain their import; types whose fields - * were converted to String IDs lose theirs. + *

Must run after all AST transformations so the reference check reflects the + * final state of the file. */ private boolean removeRemoteImports(CompilationUnit cu, Set remoteEntities) { boolean modified = false; - // Collect every ClassOrInterfaceType still present after transformation Set stillReferenced = new HashSet<>(); - cu.findAll(ClassOrInterfaceType.class) - .forEach(t -> stillReferenced.add(t.getNameAsString())); + cu.findAll(ClassOrInterfaceType.class).forEach(t -> stillReferenced.add(t.getNameAsString())); List toRemove = new ArrayList<>(); for (ImportDeclaration imp : cu.getImports()) { @@ -634,9 +1188,9 @@ private boolean removeRemoteImports(CompilationUnit cu, Set remoteEntiti return modified; } - // ------------------------------------------------------------ - // Utility Methods - // ------------------------------------------------------------ + // ========================================================================= + // Utility methods + // ========================================================================= private boolean hasAnnotation(NodeWithAnnotations node, String annoSimpleName) { return node.getAnnotations().stream() @@ -651,16 +1205,14 @@ private boolean hasAnyAnnotation(NodeWithAnnotations node, Set annoNa } private void removeAnnotationsByName(NodeWithAnnotations node, Set annoNames) { - NodeList anns = node.getAnnotations(); - anns.removeIf(a -> annoNames.contains(a.getNameAsString())); + node.getAnnotations().removeIf(a -> annoNames.contains(a.getNameAsString())); } private String simpleTypeName(Type t) { if (t.isPrimitiveType()) return null; if (t.isArrayType()) return simpleTypeName(t.asArrayType().getComponentType()); if (t.isClassOrInterfaceType()) { - ClassOrInterfaceType ct = t.asClassOrInterfaceType(); - return ct.getName().getIdentifier(); + return t.asClassOrInterfaceType().getName().getIdentifier(); } return t.asString(); } @@ -675,6 +1227,34 @@ private Optional extractGenericTypeName(Type t) { return Optional.ofNullable(name); } + /** + * Converts a plural collection field name to its decoupled ID-list name. + *

Examples: {@code "courses" → "courseIds"}, {@code "products" → "productIds"}, + * {@code "aliases" → "aliasIds"} (ends in 'ses' → strip 's', add 'Ids'). + */ + private String toIdsFieldName(String fieldName) { + if (fieldName.endsWith("s") && !fieldName.endsWith("ss") && fieldName.length() > 1) { + return fieldName.substring(0, fieldName.length() - 1) + "Ids"; + } + return fieldName + "Ids"; + } + + /** Returns a {@code List} type node for use in setType() calls. */ + private static ClassOrInterfaceType listOfString() { + return new ClassOrInterfaceType(null, "List") + .setTypeArguments(new ClassOrInterfaceType(null, "String")); + } + + /** Adds the given fully-qualified import to {@code cu} if not already present. */ + private void ensureImport(CompilationUnit cu, String fqn) { + if (cu == null) return; + boolean present = cu.getImports().stream() + .anyMatch(imp -> imp.getNameAsString().equals(fqn)); + if (!present) { + cu.addImport(fqn); + } + } + private String lowerFirst(String s) { if (s == null || s.isEmpty()) return s; return Character.toLowerCase(s.charAt(0)) + s.substring(1); @@ -684,4 +1264,4 @@ private String upperFirst(String s) { if (s == null || s.isEmpty()) return s; return Character.toUpperCase(s.charAt(0)) + s.substring(1); } -} \ No newline at end of file +} diff --git a/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ValidationNaming.java b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ValidationNaming.java new file mode 100644 index 0000000..d07e710 --- /dev/null +++ b/fractalx-core/src/main/java/org/fractalx/core/datamanagement/ValidationNaming.java @@ -0,0 +1,37 @@ +package org.fractalx.core.datamanagement; + +/** + * Centralised naming utility for generated reference-validation method names. + * + *

Both {@link RelationshipDecoupler} (call-site injection into service classes) and + * {@link ReferenceValidatorGenerator} (ReferenceValidator bean code generation) must + * produce identical method names so that injected call sites resolve at compile + * time. Any rename here automatically updates both sides. + */ +class ValidationNaming { + + private ValidationNaming() {} + + /** + * Validate method name for a singular remote ID field. + *

Example: type {@code "Payment"} → {@code "validatePaymentExists"} + */ + static String singleValidateMethod(String type) { + return "validate" + cap(type) + "Exists"; + } + + /** + * Validate method name for a collection remote ID field (produced by @ManyToMany decoupling). + *

Example: type {@code "Course"} → {@code "validateAllCourseExist"} + */ + static String collectionValidateMethod(String type) { + return "validateAll" + cap(type) + "Exist"; + } + + // ------------------------------------------------------------------------- + + private static String cap(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } +} diff --git a/fractalx-core/src/main/java/org/fractalx/core/gateway/GatewayOpenApiGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/gateway/GatewayOpenApiGenerator.java index 2a22b50..f929377 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/gateway/GatewayOpenApiGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/gateway/GatewayOpenApiGenerator.java @@ -268,8 +268,10 @@ private String buildPostmanCollection(List modules) { } private void appendServiceFolder(StringBuilder sb, FractalModule m) { - String base = pathBase(m); - String name = displayName(m); + String base = pathBase(m); // e.g. "orders" + String entity = m.getServiceName().replace("-service", ""); // e.g. "order" + String name = displayName(m); + String body = buildSampleBody(entity); sb.append(" {\n"); sb.append(" \"name\": \"").append(name).append("\",\n"); @@ -277,16 +279,16 @@ private void appendServiceFolder(StringBuilder sb, FractalModule m) { appendPostmanRequest(sb, "List " + name, "GET", "{{gateway_url}}/api/" + base, null, listTests(), true); - sb.append(",\n"); + sb.append("\n"); appendPostmanRequest(sb, "Create " + name, "POST", - "{{gateway_url}}/api/" + base, "{}", createTests(), true); - sb.append(",\n"); + "{{gateway_url}}/api/" + base, body, createTests(), true); + sb.append("\n"); appendPostmanRequest(sb, "Get " + name + " by ID", "GET", "{{gateway_url}}/api/" + base + "/{{id}}", null, getByIdTests(), true); - sb.append(",\n"); + sb.append("\n"); appendPostmanRequest(sb, "Update " + name, "PUT", - "{{gateway_url}}/api/" + base + "/{{id}}", "{}", updateTests(), true); - sb.append(",\n"); + "{{gateway_url}}/api/" + base + "/{{id}}", body, updateTests(), true); + sb.append("\n"); appendPostmanRequest(sb, "Delete " + name, "DELETE", "{{gateway_url}}/api/" + base + "/{{id}}", null, deleteTests(), false); @@ -397,8 +399,86 @@ private String[] deleteTests() { // Helpers // ------------------------------------------------------------------------- + /** Strips the "-service" suffix and pluralises for REST path conventions. */ private String pathBase(FractalModule m) { - return m.getServiceName().replace("-service", ""); + String entity = m.getServiceName().replace("-service", ""); + return pluralize(entity); + } + + /** Simple English pluralisation sufficient for typical entity names. */ + private String pluralize(String word) { + if (word.endsWith("ry")) return word.substring(0, word.length() - 2) + "ries"; // inventory→inventories + if (word.endsWith("y")) return word.substring(0, word.length() - 1) + "ies"; // category→categories + if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") + || word.endsWith("ch") || word.endsWith("sh")) + return word + "es"; + return word + "s"; + } + + /** + * Builds a realistic JSON sample request body based on the entity name. + * Used in POST/PUT Postman requests so testers have a real starting point. + */ + private String buildSampleBody(String entity) { + return switch (entity) { + case "order" -> """ + { + "customerId": 1, + "items": [ + { "productId": 1, "quantity": 2, "unitPrice": 29.99 } + ], + "shippingAddress": "123 Main St, Springfield", + "notes": "Leave at door" + }"""; + case "payment" -> """ + { + "orderId": 1, + "amount": 59.98, + "currency": "USD", + "method": "CREDIT_CARD", + "cardLast4": "4242" + }"""; + case "inventory", "product" -> """ + { + "name": "Sample Product", + "description": "A short product description", + "sku": "SKU-0001", + "quantity": 100, + "price": 29.99 + }"""; + case "budget" -> """ + { + "name": "Q2 Operating Budget", + "amount": 50000.00, + "currency": "USD", + "period": "2026-Q2", + "category": "OPERATIONS" + }"""; + case "user", "customer" -> """ + { + "firstName": "Jane", + "lastName": "Doe", + "email": "jane.doe@example.com", + "phone": "+1-555-0100" + }"""; + case "notification" -> """ + { + "recipientId": 1, + "type": "EMAIL", + "subject": "Your order has been processed", + "body": "Thank you for your order." + }"""; + default -> """ + { + "name": "Sample %s", + "description": "Auto-generated placeholder — replace with real fields", + "status": "ACTIVE" + }""".formatted(capitalize(entity)); + }; + } + + private String capitalize(String s) { + return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1); } private String displayName(FractalModule m) { @@ -416,6 +496,8 @@ private String displayName(FractalModule m) { private String escapeJson(String s) { return s.replace("\\", "\\\\") .replace("\"", "\\\"") - .replace("\t", "\\t"); + .replace("\t", "\\t") + .replace("\n", "\\n") + .replace("\r", ""); } } diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/ServiceGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/ServiceGenerator.java index f2d3570..0f2e32d 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/ServiceGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/ServiceGenerator.java @@ -18,6 +18,7 @@ import org.fractalx.core.generator.service.NetScopeClientGenerator; import org.fractalx.core.generator.service.NetScopeRegistryBridgeStep; import org.fractalx.core.generator.service.PomGenerator; +import org.fractalx.core.generator.service.DbSummaryStep; import org.fractalx.core.generator.service.ServiceRegistrationStep; import org.fractalx.core.generator.transformation.AnnotationRemover; import org.fractalx.core.generator.transformation.CodeCopier; @@ -119,6 +120,7 @@ private List buildPipeline() { new CorrelationIdGenerator(), // generates logback-spring.xml with %X{correlationId} new OtelConfigStep(), new HealthMetricsStep(), + new DbSummaryStep(), new ServiceRegistrationStep(), new NetScopeRegistryBridgeStep(), new ResilienceConfigStep() diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminDataConsistencyGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminDataConsistencyGenerator.java index 542c870..9ef2b22 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminDataConsistencyGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminDataConsistencyGenerator.java @@ -1,15 +1,20 @@ package org.fractalx.core.generator.admin; +import org.fractalx.core.config.FractalxConfig; import org.fractalx.core.model.FractalModule; import org.fractalx.core.model.SagaDefinition; import org.fractalx.core.model.SagaStep; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Generates the Data Consistency section for the admin service: @@ -23,15 +28,26 @@ class AdminDataConsistencyGenerator { private static final Logger log = LoggerFactory.getLogger(AdminDataConsistencyGenerator.class); void generate(Path srcMainJava, String basePackage, List modules) throws IOException { - generate(srcMainJava, basePackage, modules, List.of()); + generate(srcMainJava, basePackage, modules, List.of(), FractalxConfig.defaults(), null); } void generate(Path srcMainJava, String basePackage, List modules, List sagaDefinitions) throws IOException { + generate(srcMainJava, basePackage, modules, sagaDefinitions, FractalxConfig.defaults(), null); + } + + void generate(Path srcMainJava, String basePackage, List modules, + List sagaDefinitions, FractalxConfig fractalxConfig) throws IOException { + generate(srcMainJava, basePackage, modules, sagaDefinitions, fractalxConfig, null); + } + + void generate(Path srcMainJava, String basePackage, List modules, + List sagaDefinitions, FractalxConfig fractalxConfig, + Path outputRoot) throws IOException { Path pkg = AdminPackageUtil.createPackagePath(srcMainJava, basePackage + ".data"); generateSagaMetaRegistry(pkg, modules, sagaDefinitions); - generateDataConsistencyController(pkg, modules); + generateDataConsistencyController(pkg, modules, fractalxConfig, outputRoot); log.debug("Generated admin data consistency components"); } @@ -127,31 +143,68 @@ public Optional findById(String sagaId) { Files.writeString(pkg.resolve("SagaMetaRegistry.java"), content); } - private void generateDataConsistencyController(Path pkg, List modules) - throws IOException { + private void generateDataConsistencyController(Path pkg, List modules, + FractalxConfig fractalxConfig, + Path outputRoot) throws IOException { - // Generate health check calls per service for DB endpoint + // Build baked datasource config constants and DB checks + StringBuilder dsConfigEntries = new StringBuilder(); StringBuilder dbChecks = new StringBuilder(); + for (FractalModule m : modules) { - if (m.getOwnedSchemas() != null && !m.getOwnedSchemas().isEmpty()) { - String schemas = String.join(", ", m.getOwnedSchemas()); - dbChecks.append(String.format( - " {Map db = new LinkedHashMap<>(); db.put(\"service\",\"%s\"); db.put(\"schemas\",\"%s\"); db.put(\"health\", fetchDbHealth(%d)); dbList.add(db);}\n", - m.getServiceName(), schemas, m.getPort())); - } else { - dbChecks.append(String.format( - " {Map db = new LinkedHashMap<>(); db.put(\"service\",\"%s\"); db.put(\"schemas\",\"default\"); db.put(\"health\", fetchDbHealth(%d)); dbList.add(db);}\n", - m.getServiceName(), m.getPort())); - } + String svcName = m.getServiceName(); + FractalxConfig.ServiceOverride ov = fractalxConfig.serviceOverrides().get(svcName); + + // Read actual values from the generated service's application YAMLs (most accurate) + Map svcYaml = readServiceYamlConfig(outputRoot, svcName); + + // datasource config constants for admin — service YAML takes priority over fractalx-config.yml + String defaultH2Url = "jdbc:h2:mem:" + svcName.replace("-", "_"); + String dsUrl = svcYaml.getOrDefault("url", + (ov != null && ov.hasDatasource()) ? ov.datasourceUrl() : defaultH2Url); + String dsUser = svcYaml.getOrDefault("username", + (ov != null && ov.hasDatasource()) ? ov.datasourceUsername() : "sa"); + String dsPass = svcYaml.getOrDefault("password", + (ov != null && ov.hasDatasource()) ? ov.datasourcePassword() : ""); + String dsDrv = svcYaml.getOrDefault("driver", + (ov != null && ov.hasDatasource()) + ? (ov.datasourceDriver() != null ? ov.datasourceDriver() : deriveDriver(dsUrl)) + : "org.h2.Driver"); + int port = svcYaml.containsKey("port") + ? parseInt(svcYaml.get("port"), m.getPort()) : m.getPort(); + boolean isH2 = dsUrl.startsWith("jdbc:h2"); + + dsConfigEntries.append(String.format( + " DS_CONFIG.put(\"%s\", new DsConfig(\"%s\",\"%s\",\"%s\",\"%s\",%s,%d));\n", + svcName, dsUrl, dsUser, dsPass, dsDrv, isH2, port)); + + String schemas = (m.getOwnedSchemas() != null && !m.getOwnedSchemas().isEmpty()) + ? String.join(", ", m.getOwnedSchemas()) : "default"; + dbChecks.append(String.format( + " {Map db = new LinkedHashMap<>(); db.put(\"service\",\"%s\"); db.put(\"schemas\",\"%s\"); db.put(\"health\", fetchDbHealth(%d)); db.put(\"dbSummary\", fetchDbSummary(%d)); dbList.add(db);}\n", + svcName, schemas, port, port)); } - // Append saga-orchestrator DB (H2 in-memory, port 8099) + // Append saga-orchestrator DB — read from generated YAML, fall back to known defaults + { + Map sagaYaml = readServiceYamlConfig(outputRoot, "fractalx-saga-orchestrator"); + String sagaUrl = sagaYaml.getOrDefault("url", "jdbc:h2:mem:saga_db"); + String sagaUser = sagaYaml.getOrDefault("username", "sa"); + String sagaPass = sagaYaml.getOrDefault("password", ""); + String sagaDrv = sagaYaml.getOrDefault("driver", "org.h2.Driver"); + int sagaPort = sagaYaml.containsKey("port") ? parseInt(sagaYaml.get("port"), 8099) : 8099; + boolean sagaH2 = sagaUrl.startsWith("jdbc:h2"); + dsConfigEntries.append(String.format( + " DS_CONFIG.put(\"saga-orchestrator\", new DsConfig(\"%s\",\"%s\",\"%s\",\"%s\",%s,%d));\n", + sagaUrl, sagaUser, sagaPass, sagaDrv, sagaH2, sagaPort)); + } dbChecks.append( " {Map db = new LinkedHashMap<>();" + " db.put(\"service\",\"saga-orchestrator\");" + " db.put(\"schemas\",\"saga_instance\");" + " db.put(\"health\", fetchDbHealth(SAGA_ORCHESTRATOR_PORT));" + " db.put(\"instanceCount\", fetchSagaInstanceCount());" + + " db.put(\"dbSummary\", fetchDbSummary(SAGA_ORCHESTRATOR_PORT));" + " dbList.add(db);}\n" ); @@ -178,13 +231,14 @@ private void generateDataConsistencyController(Path pkg, List mod * REST API for the Data Consistency section of the admin dashboard. * *

-                 * GET /api/data/overview              — summary of all data consistency features
-                 * GET /api/data/sagas                 — all baked saga definitions
-                 * GET /api/data/sagas/instances       — proxy to saga-orchestrator GET /saga
-                 * GET /api/data/sagas/{sagaId}/instances — instances filtered by sagaId
-                 * GET /api/data/databases             — per-service DB health (actuator/health/db)
-                 * GET /api/data/schemas               — per-service owned schemas
-                 * GET /api/data/outbox                — per-service outbox metrics
+                 * GET /api/data/overview                     — summary of all data consistency features
+                 * GET /api/data/sagas                        — all baked saga definitions
+                 * GET /api/data/sagas/instances              — proxy to saga-orchestrator GET /saga
+                 * GET /api/data/sagas/{sagaId}/instances     — instances filtered by sagaId
+                 * GET /api/data/databases                    — per-service DB health + row counts
+                 * GET /api/data/databases/config/{service}   — datasource config details for a service
+                 * GET /api/data/schemas                      — per-service owned schemas
+                 * GET /api/data/outbox                       — per-service outbox metrics
                  * 
*/ @RestController @@ -194,6 +248,14 @@ public class DataConsistencyController { private static final int SAGA_ORCHESTRATOR_PORT = 8099; + /** Baked-in datasource configuration per service (from fractalx-config.yml). */ + public record DsConfig(String url, String username, String password, + String driverClassName, boolean isH2, int port) {} + + private static final Map DS_CONFIG = new LinkedHashMap<>(); + static { + %s } + private final SagaMetaRegistry registry; private final RestTemplate restTemplate = new RestTemplate(); @@ -301,13 +363,39 @@ public ResponseEntity getEnrichedInstances() { } } - /** Per-service database health from /actuator/health/db. */ + /** Per-service database health + row counts from /api/internal/db-summary. */ @GetMapping("/databases") public ResponseEntity>> getDatabases() { List> dbList = new ArrayList<>(); %s return ResponseEntity.ok(dbList); } + /** + * Returns baked datasource config for a service (url, username, driver, isH2). + * Password is included for H2 (empty) but masked as "***" for external DBs. + */ + @GetMapping("/databases/config/{service}") + public ResponseEntity> getDatabaseConfig( + @PathVariable("service") String service) { + DsConfig cfg = DS_CONFIG.get(service); + if (cfg == null) { + return ResponseEntity.ok(Map.of("error", "No config found for " + service)); + } + Map result = new LinkedHashMap<>(); + result.put("service", service); + result.put("url", cfg.url()); + result.put("username", cfg.username()); + result.put("password", cfg.isH2() ? cfg.password() : "***"); + result.put("driverClassName", cfg.driverClassName()); + result.put("isH2", cfg.isH2()); + result.put("port", cfg.port()); + if (cfg.isH2()) { + result.put("h2ConsoleUrl", + "http://localhost:" + cfg.port() + "/h2-console"); + } + return ResponseEntity.ok(result); + } + /** Per-service owned schema info (baked from generation metadata). */ @GetMapping("/schemas") public ResponseEntity>> getSchemas() { @@ -339,6 +427,28 @@ private String fetchDbHealth(int port) { } } + @SuppressWarnings("unchecked") + private Map fetchDbSummary(int port) { + try { + Map resp = restTemplate.getForObject( + "http://localhost:" + port + "/api/internal/db-summary", Map.class); + // restTemplate.getForObject can return null for empty responses — normalise to empty summary + if (resp == null) { + return new LinkedHashMap<>(Map.of("totalRows", 0, "entityCounts", Map.of())); + } + // Ensure totalRows key is always present so the UI can display the count + if (!resp.containsKey("totalRows")) { + Map normalised = new LinkedHashMap<>(resp); + normalised.put("totalRows", 0); + return normalised; + } + return resp; + } catch (Exception e) { + // Service unavailable — return sentinel so JS shows "—" rather than 0 + return Map.of("unavailable", true, "reason", e.getMessage()); + } + } + private Object fetchOutboxMetrics(int port) { try { return restTemplate.getForObject( @@ -370,13 +480,80 @@ private int fetchSagaInstanceCount() { } } } - """.formatted(serviceCount, dbChecks.toString(), outboxChecks.toString()); + """.formatted(dsConfigEntries.toString(), serviceCount, dbChecks.toString(), outboxChecks.toString()); Files.writeString(pkg.resolve("DataConsistencyController.java"), content); } // ------------------------------------------------------------------------- + private String deriveDriver(String url) { + if (url == null) return "org.h2.Driver"; + if (url.startsWith("jdbc:mysql")) return "com.mysql.cj.jdbc.Driver"; + if (url.startsWith("jdbc:postgresql")) return "org.postgresql.Driver"; + if (url.startsWith("jdbc:mariadb")) return "org.mariadb.jdbc.Driver"; + if (url.startsWith("jdbc:oracle")) return "oracle.jdbc.OracleDriver"; + if (url.startsWith("jdbc:sqlserver")) return "com.microsoft.sqlserver.jdbc.SQLServerDriver"; + return "org.h2.Driver"; + } + + private int parseInt(String s, int fallback) { + try { return Integer.parseInt(s.trim()); } catch (Exception e) { return fallback; } + } + + /** + * Reads datasource URL, driver, username, password, and server port from a generated + * service's {@code application.yml} and {@code application-dev.yml} files. + * Returns an empty map (all keys absent) if the files cannot be read — callers fall back + * to {@link FractalxConfig} values in that case. + */ + @SuppressWarnings("unchecked") + private Map readServiceYamlConfig(Path outputRoot, String serviceName) { + Map result = new HashMap<>(); + if (outputRoot == null) return result; + Yaml yaml = new Yaml(); + + // Base application.yml: server.port and (for some services) spring.datasource.* + Path baseYml = outputRoot.resolve(serviceName).resolve("src/main/resources/application.yml"); + if (Files.exists(baseYml)) { + try (InputStream is = Files.newInputStream(baseYml)) { + Map data = yaml.load(is); + if (data != null) { + if (data.get("server") instanceof Map serverMap) { + Object p = serverMap.get("port"); + if (p != null) result.put("port", String.valueOf(p)); + } + // Some services (e.g. saga-orchestrator) put datasource in the base YAML + extractDatasource(data, result); + } + } catch (Exception ignored) {} + } + + // Dev profile YAML overrides datasource config for regular services + // (application-dev.yml takes priority over application.yml for datasource values) + Path devYml = outputRoot.resolve(serviceName).resolve("src/main/resources/application-dev.yml"); + if (Files.exists(devYml)) { + try (InputStream is = Files.newInputStream(devYml)) { + Map data = yaml.load(is); + if (data != null) { + extractDatasource(data, result); // dev values win + } + } catch (Exception ignored) {} + } + return result; + } + + /** Extracts spring.datasource.{url,driver-class-name,username,password} into result map. */ + private void extractDatasource(Map data, Map result) { + if (!(data.get("spring") instanceof Map springMap)) return; + if (!(springMap.get("datasource") instanceof Map dsMap)) return; + if (dsMap.get("url") != null) result.put("url", String.valueOf(dsMap.get("url"))); + if (dsMap.get("driver-class-name") != null) result.put("driver", String.valueOf(dsMap.get("driver-class-name"))); + if (dsMap.get("username") != null) result.put("username", String.valueOf(dsMap.get("username"))); + Object pwd = dsMap.get("password"); + result.put("password", (pwd != null && !"null".equals(String.valueOf(pwd))) ? String.valueOf(pwd) : ""); + } + /** Removes a trailing {@code ,\n} so List.of() method calls don't end with a stray comma. */ private String stripTrailingComma(String s) { return s.endsWith(",\n") ? s.substring(0, s.length() - 2) + "\n" : s; diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminObservabilityGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminObservabilityGenerator.java index c7778e7..f8323df 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminObservabilityGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminObservabilityGenerator.java @@ -750,12 +750,36 @@ public ResponseEntity getTraces( && (service == null || service.isBlank())) { return searchByCorrelationAcrossAllServices(correlationId, limit); } - StringBuilder url = new StringBuilder(jaegerQueryUrl + "/api/traces?limit=" + limit); - if (service != null && !service.isBlank()) url.append("&service=").append(service); - if (correlationId != null && !correlationId.isBlank()) { - String tagsJson = "{\\"correlation.id\\":\\"" + correlationId.replace("\\"", "") + "\\"}"; - url.append("&tags=").append(java.net.URLEncoder.encode(tagsJson, java.nio.charset.StandardCharsets.UTF_8)); + // Single-service search: try Jaeger tag search, fall back to in-memory scan + if (correlationId != null && !correlationId.isBlank() + && service != null && !service.isBlank()) { + String tagsJson = "{\\"correlationId\\":\\"" + correlationId.replace("\\"", "") + "\\"}"; + String tagUrl = jaegerQueryUrl + "/api/traces?limit=" + limit + "&lookback=168h" + + "&service=" + java.net.URLEncoder.encode(service, java.nio.charset.StandardCharsets.UTF_8) + + "&tags=" + java.net.URLEncoder.encode(tagsJson, java.nio.charset.StandardCharsets.UTF_8); + Map tagResult = rest.getForObject(tagUrl, Map.class); + List tagData = (tagResult != null && tagResult.get("data") instanceof List) + ? (List) tagResult.get("data") : List.of(); + if (!tagData.isEmpty()) { + return ResponseEntity.ok(tagResult); + } + // Fallback: fetch all traces for this service and filter in memory + String allUrl = jaegerQueryUrl + "/api/traces?limit=" + limit + "&lookback=168h" + + "&service=" + java.net.URLEncoder.encode(service, java.nio.charset.StandardCharsets.UTF_8); + Map allResult = rest.getForObject(allUrl, Map.class); + if (allResult != null && allResult.get("data") instanceof List allData) { + List filtered = new java.util.ArrayList<>(); + for (Object t : allData) { + if (t instanceof Map tm && spanTagMatchesCorrelationId(tm, correlationId)) { + filtered.add(t); + } + } + return ResponseEntity.ok(Map.of("data", filtered)); + } + return ResponseEntity.ok(Map.of("data", List.of())); } + StringBuilder url = new StringBuilder(jaegerQueryUrl + "/api/traces?limit=" + limit + "&lookback=168h"); + if (service != null && !service.isBlank()) url.append("&service=").append(service); return ResponseEntity.ok(rest.getForObject(url.toString(), Object.class)); } catch (Exception e) { return ResponseEntity.ok(Map.of("error", "Jaeger unavailable: " + e.getMessage())); @@ -770,23 +794,44 @@ private ResponseEntity searchByCorrelationAcrossAllServices(String corre List services = svcResp != null && svcResp.get("data") instanceof List ? (List) svcResp.get("data") : List.of(); - // 2. Query each service for traces with this correlation ID List merged = new java.util.ArrayList<>(); java.util.Set seen = new java.util.HashSet<>(); for (String svc : services) { try { - String tagsJson = "{\\"correlation.id\\":\\"" + correlationId.replace("\\"", "") + "\\"}"; - String url = jaegerQueryUrl + "/api/traces?service=" + svc + // 2a. Try Jaeger tag-index search first (fast path) + String tagsJson = "{\\"correlationId\\":\\"" + correlationId.replace("\\"", "") + "\\"}"; + String tagUrl = jaegerQueryUrl + "/api/traces?service=" + + java.net.URLEncoder.encode(svc, java.nio.charset.StandardCharsets.UTF_8) + "&tags=" + java.net.URLEncoder.encode(tagsJson, java.nio.charset.StandardCharsets.UTF_8) - + "&limit=" + limit; - Map result = rest.getForObject(url, Map.class); - if (result != null && result.get("data") instanceof List data) { - for (Object trace : data) { + + "&limit=" + limit + "&lookback=168h"; + Map tagResult = rest.getForObject(tagUrl, Map.class); + List tagData = (tagResult != null && tagResult.get("data") instanceof List) + ? (List) tagResult.get("data") : List.of(); + if (!tagData.isEmpty()) { + for (Object trace : tagData) { if (trace instanceof Map t) { String tid = String.valueOf(t.get("traceID")); if (seen.add(tid)) merged.add(trace); } } + } else { + // 2b. Fallback: fetch all recent traces and scan span tags in memory. + // Needed when Jaeger tag indexing is disabled or hasn't indexed the tag yet. + String allUrl = jaegerQueryUrl + "/api/traces?service=" + + java.net.URLEncoder.encode(svc, java.nio.charset.StandardCharsets.UTF_8) + + "&limit=" + limit + "&lookback=168h"; + Map allResult = rest.getForObject(allUrl, Map.class); + if (allResult != null && allResult.get("data") instanceof List allData) { + for (Object trace : allData) { + if (trace instanceof Map t) { + String tid = String.valueOf(t.get("traceID")); + if (!seen.contains(tid) && spanTagMatchesCorrelationId(t, correlationId)) { + seen.add(tid); + merged.add(trace); + } + } + } + } } } catch (Exception ignored) {} } @@ -796,6 +841,30 @@ private ResponseEntity searchByCorrelationAcrossAllServices(String corre } } + /** Scans all spans in a trace for a correlationId tag (any known key variant). */ + @SuppressWarnings("unchecked") + private boolean spanTagMatchesCorrelationId(Map trace, String correlationId) { + Object spans = trace.get("spans"); + if (!(spans instanceof List spanList)) return false; + for (Object span : spanList) { + if (!(span instanceof Map spanMap)) continue; + Object tags = spanMap.get("tags"); + if (!(tags instanceof List tagList)) continue; + for (Object tag : tagList) { + if (!(tag instanceof Map tagMap)) continue; + String key = String.valueOf(tagMap.get("key")); + String val = String.valueOf(tagMap.get("value")); + if (correlationId.equals(val) + && (key.equals("correlationId") + || key.equals("x-correlation-id") + || key.equals("correlation.id"))) { + return true; + } + } + } + return false; + } + @GetMapping("/traces/services") public ResponseEntity getJaegerServices() { try { diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminServiceGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminServiceGenerator.java index 755f943..c724d2b 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminServiceGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminServiceGenerator.java @@ -145,7 +145,7 @@ public void generateAdminService(List modules, Path outputRoot, P // New enhanced sub-systems servicesDetailGenerator.generate(srcMainJava, BASE_PACKAGE, modules, sagaDefinitions); communicationGenerator.generate(srcMainJava, BASE_PACKAGE, modules); - dataConsistencyGenerator.generate(srcMainJava, BASE_PACKAGE, modules, sagaDefinitions); + dataConsistencyGenerator.generate(srcMainJava, BASE_PACKAGE, modules, sagaDefinitions, fractalxConfig, outputRoot); userManagementGenerator.generate(srcMainJava, BASE_PACKAGE); databaseGenerator.generate(srcMainJava, BASE_PACKAGE); configManagementGenerator.generate(srcMainJava, BASE_PACKAGE, modules, fractalxConfig); diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminTemplateGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminTemplateGenerator.java index a601e75..bfb9b5a 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminTemplateGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/admin/AdminTemplateGenerator.java @@ -285,6 +285,7 @@ private String buildHtmlHeadB() { .badge-down{background:#fee2e2!important;color:#b91c1c!important} .badge-degraded{background:#fef3c7!important;color:#92400e!important} .badge-unknown{background:#f3f4f6!important;color:#6b7280!important} + .badge-info{background:#dbeafe!important;color:#1d4ed8!important} /* Process status pills */ .proc-pill{display:inline-flex;align-items:center;gap:4px;font-size:11px;font-weight:600; padding:2px 8px;border-radius:20px;letter-spacing:.02em} @@ -948,10 +949,10 @@ private String buildSectionData() {
- + - +
ServiceSchemasHealthServiceSchemasHealthRow CountActions
Loading…
Loading…
@@ -1489,6 +1490,20 @@ private String buildModals() { + """; } @@ -1933,19 +1948,24 @@ function loadDataConsistency() { tbody.innerHTML = ''; dbs.forEach(db => { const h = db.health || 'UNKNOWN'; - const extra = db.instanceCount !== undefined - ? `${db.instanceCount >= 0 ? db.instanceCount + ' rows' : '—'}` - : ''; + const summary = db.dbSummary; + let rowCount = '—'; + if (summary && !summary.unavailable && summary.totalRows !== undefined) { + rowCount = summary.totalRows + ' rows'; + } else if (db.instanceCount !== undefined && db.instanceCount >= 0) { + rowCount = db.instanceCount + ' rows'; + } tbody.innerHTML += ` ${db.service} ${db.schemas || '-'} ${h} - ${extra} + ${rowCount} + `; }); }).catch(() => { document.getElementById('databases-tbody').innerHTML = - 'DB health unavailable'; + 'DB health unavailable'; }); fetch('/api/data/outbox') @@ -2119,6 +2139,34 @@ function toggleStepDetail(id) { const el = document.getElementById(id); if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none'; } + + function showDbDetails(service) { + fetch('/api/data/databases/config/' + service) + .then(r => r.json()).then(cfg => { + const isH2 = cfg.isH2; + let html = ` + + + + + + + +
Service${cfg.service}
URL${cfg.url || '-'}
Username${cfg.username || '-'}
Password${cfg.password || (isH2 ? '(empty)' : '***')}
Driver${cfg.driverClassName || '-'}
Type${isH2 ? 'H2 (in-memory)' : 'External'}
`; + if (isH2 && cfg.h2ConsoleUrl) { + html += `
+ + Open H2 Console + + JDBC URL: ${cfg.url}   User: ${cfg.username}   Password: leave empty +
`; + } + document.getElementById('db-details-body').innerHTML = html; + document.getElementById('db-details-title').textContent = cfg.service + ' — Database Details'; + const modal = new bootstrap.Modal(document.getElementById('dbDetailsModal')); + modal.show(); + }).catch(e => alert('Could not load DB config: ' + e)); + } """; } diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/observability/OtelConfigStep.java b/fractalx-core/src/main/java/org/fractalx/core/generator/observability/OtelConfigStep.java index 4801aec..716a980 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/observability/OtelConfigStep.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/observability/OtelConfigStep.java @@ -72,8 +72,8 @@ private String buildCorrelationConfig(String pkg) { /** * Tags the active Micrometer/OTel span with the current request's correlation ID. * - *

The tag key "correlation.id" matches what the admin service uses when querying - * Jaeger ({@code /api/traces?tags=correlation.id%%3D}), so correlation ID + *

The tag key "correlationId" matches what the admin service uses when querying + * Jaeger ({@code /api/traces?tags={"correlationId":""}), so correlation ID * search in the admin UI traces tab will work out of the box. * *

This interceptor runs in {@code preHandle()} which is guaranteed to execute @@ -100,7 +100,7 @@ public boolean preHandle(@NonNull HttpServletRequest request, if (correlationId != null && !correlationId.isBlank()) { io.micrometer.tracing.Span span = tracer.currentSpan(); if (span != null) { - span.tag("correlation.id", correlationId); + span.tag("correlationId", correlationId); } } return true; diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/saga/SagaOrchestratorGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/saga/SagaOrchestratorGenerator.java index 5700230..fd05ed7 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/saga/SagaOrchestratorGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/saga/SagaOrchestratorGenerator.java @@ -696,7 +696,7 @@ private String buildSagaService(SagaDefinition saga) { sb.append(" // or generate a fresh UUID if the caller did not supply one.\n"); sb.append(" String correlationId = (incomingCorrelationId != null && !incomingCorrelationId.isBlank())\n"); sb.append(" ? incomingCorrelationId\n"); - sb.append(" : UUID.randomUUID().toString();\n\n"); + sb.append(" : (MDC.get(\"correlationId\") != null ? MDC.get(\"correlationId\") : UUID.randomUUID().toString());\n\n"); sb.append(" SagaInstance instance = new SagaInstance();\n"); sb.append(" instance.setSagaId(\"").append(saga.getSagaId()).append("\");\n"); diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/service/CorrelationIdGenerator.java b/fractalx-core/src/main/java/org/fractalx/core/generator/service/CorrelationIdGenerator.java index 4282b87..7ca64d1 100644 --- a/fractalx-core/src/main/java/org/fractalx/core/generator/service/CorrelationIdGenerator.java +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/service/CorrelationIdGenerator.java @@ -24,9 +24,9 @@ * aggregation by a single correlation ID. * * - *

Generated log format: + *

Generated log format (merged Spring Boot + correlation-ID style): *

- * 2026-03-04 12:00:00.123 [abc-123] INFO  o.f.g.orderservice.OrderService - Processing order
+ * 2026-03-08T13:11:17.533+05:30 [NO_CORR] DEBUG 15204 --- [order-service] [   scheduling-1] o.f.order.OrderService                   : Processing order
  * 
* *

Auto-generated by FractalX — Layer 5 (Correlation ID / Distributed Tracing). @@ -56,22 +56,43 @@ public void generate(GenerationContext context) throws IOException { // ------------------------------------------------------------------------- private String buildLogbackXml(String serviceName) { - // Use plain string concatenation to avoid conflicts between Java format specifiers - // (e.g. %X, %d, %n) and Logback's own MDC/pattern syntax. - String logPattern = "%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{correlationId:-NO_CORR}] %-5level %logger{36} - %msg%n"; + // Merged pattern — Spring Boot standard columns + FractalX correlationId MDC key. + // Columns (left to right): + // timestamp (ISO-8601 + zone offset) | correlationId | level | PID | --- | [app-name] | [thread] | logger | : message + // Example: + // 2026-03-08T13:11:17.533+05:30 [NO_CORR] DEBUG 15204 --- [order-service] [ scheduling-1] o.f.order.OrderService : Processing order + // + // Notes on Logback pattern tokens (NOT Java format specifiers): + // %d{...} — timestamp with Logback date pattern + // %X{key:-default} — MDC value (correlationId set by TraceFilter / NetScopeContextInterceptor) + // ${PID:-????} — Spring Boot sets PID as a system property at startup; Logback substitutes it. + // ProcessIdConverter (%pid) was removed in Spring Boot 3.x — use ${PID} instead. + // ${APP_NAME} — Logback context property resolved from + // %15.15t — thread name, right-padded / truncated to 15 chars + // %-40.40logger{39}— logger name abbreviated to 39 chars, left-padded to 40 + String logPattern = + "%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%X{correlationId:-NO_CORR}] %-5level ${PID:-????} --- [${APP_NAME}] [%15.15t] %-40.40logger{39} : %msg%n"; + return "\n" + "\n" + "\n" + "\n" - + " \n" + + " \n" + + " \n" + + "\n" + + " \n" + + " \n" + + "\n" + + " \n" + " \n" + " \n" - + " " + logPattern + "\n" + + " ${LOG_PATTERN}\n" + " \n" + " \n" + "\n" @@ -83,11 +104,11 @@ private String buildLogbackXml(String serviceName) { + " 30\n" + " \n" + " \n" - + " " + logPattern + "\n" + + " ${LOG_PATTERN}\n" + " \n" + " \n" + "\n" - + " \n" + + " \n" + " \n" + " \n" + " \n" diff --git a/fractalx-core/src/main/java/org/fractalx/core/generator/service/DbSummaryStep.java b/fractalx-core/src/main/java/org/fractalx/core/generator/service/DbSummaryStep.java new file mode 100644 index 0000000..5a49751 --- /dev/null +++ b/fractalx-core/src/main/java/org/fractalx/core/generator/service/DbSummaryStep.java @@ -0,0 +1,156 @@ +package org.fractalx.core.generator.service; + +import org.fractalx.core.generator.GenerationContext; +import org.fractalx.core.generator.ServiceFileGenerator; +import org.fractalx.core.model.FractalModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +/** + * Pipeline step that generates a {@code DbSummaryController} in each service. + * + *

The controller exposes {@code GET /api/internal/db-summary} which returns + * row counts for every JPA entity registered in the service's + * {@link jakarta.persistence.EntityManager}, plus the JDBC URL and driver class name. + * The admin service polls this endpoint to populate the Database Health table. + */ +public class DbSummaryStep implements ServiceFileGenerator { + + private static final Logger log = LoggerFactory.getLogger(DbSummaryStep.class); + + @Override + public void generate(GenerationContext context) throws IOException { + FractalModule module = context.getModule(); + Set imports = module.getDetectedImports(); + boolean hasJpa = imports != null && imports.stream().anyMatch(i -> + i.startsWith("jakarta.persistence") || + i.startsWith("javax.persistence") || + i.startsWith("org.springframework.data.jpa") || + i.startsWith("org.springframework.data.repository")); + if (!hasJpa) { + log.debug("No JPA entities in {} — skipping DbSummaryController", module.getServiceName()); + return; + } + String basePackage = "org.fractalx.generated." + toJavaId(module.getServiceName()).toLowerCase(); + Path pkgPath = resolvePackage(context.getSrcMainJava(), basePackage); + + Files.writeString(pkgPath.resolve("DbSummaryController.java"), buildContent(basePackage)); + log.debug("Generated DbSummaryController for {}", module.getServiceName()); + } + + private String buildContent(String pkg) { + return """ + package %s; + + import jakarta.persistence.EntityManager; + import jakarta.persistence.PersistenceContext; + import jakarta.persistence.metamodel.EntityType; + import org.springframework.beans.factory.annotation.Value; + import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; + import org.springframework.http.ResponseEntity; + import org.springframework.web.bind.annotation.*; + + import java.util.*; + + /** + * Internal endpoint consumed by the FractalX admin service. + * Returns entity row counts + datasource metadata for the Database Health table. + * + *

Not part of the public API — secured at network level by default. + */ + @RestController + @RequestMapping("/api/internal") + public class DbSummaryController { + + @PersistenceContext + private EntityManager entityManager; + + @Value("${spring.datasource.url:unknown}") + private String datasourceUrl; + + @Value("${spring.datasource.driver-class-name:unknown}") + private String driverClassName; + + @Value("${spring.datasource.username:sa}") + private String datasourceUsername; + + @Value("${spring.application.name:unknown}") + private String serviceName; + + /** + * Returns row counts for every JPA entity and datasource metadata. + * + *

+                     * {
+                     *   "service": "order-service",
+                     *   "datasourceUrl": "jdbc:h2:mem:orderdb",
+                     *   "driverClassName": "org.h2.Driver",
+                     *   "username": "sa",
+                     *   "isH2": true,
+                     *   "entityCounts": {
+                     *     "Order": 12,
+                     *     "OrderItem": 34
+                     *   },
+                     *   "totalRows": 46
+                     * }
+                     * 
+ */ + @GetMapping("/db-summary") + public ResponseEntity> getDbSummary() { + Map result = new LinkedHashMap<>(); + result.put("service", serviceName); + result.put("datasourceUrl", datasourceUrl); + result.put("driverClassName", driverClassName); + result.put("username", datasourceUsername); + result.put("isH2", datasourceUrl != null && datasourceUrl.startsWith("jdbc:h2")); + + Map entityCounts = new LinkedHashMap<>(); + long totalRows = 0; + + try { + Set> entities = entityManager.getMetamodel().getEntities(); + for (EntityType entity : entities) { + String entityName = entity.getName(); + try { + Long count = entityManager + .createQuery("SELECT COUNT(e) FROM " + entityName + " e", Long.class) + .getSingleResult(); + entityCounts.put(entityName, count); + totalRows += count; + } catch (Exception ex) { + entityCounts.put(entityName, -1L); + } + } + } catch (Exception ex) { + result.put("error", ex.getMessage()); + } + + result.put("entityCounts", entityCounts); + result.put("totalRows", totalRows); + return ResponseEntity.ok(result); + } + } + """.formatted(pkg); + } + + private Path resolvePackage(Path base, String pkg) throws IOException { + Path p = base; + for (String part : pkg.split("\\.")) p = p.resolve(part); + Files.createDirectories(p); + return p; + } + + private String toJavaId(String serviceName) { + StringBuilder sb = new StringBuilder(); + for (String part : serviceName.split("-")) { + if (!part.isEmpty()) + sb.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return sb.toString(); + } +} diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/DependencyManagerSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/DependencyManagerSpec.groovy new file mode 100644 index 0000000..9569b45 --- /dev/null +++ b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/DependencyManagerSpec.groovy @@ -0,0 +1,118 @@ +package org.fractalx.core.datamanagement + +import org.fractalx.core.model.FractalModule +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path + +class DependencyManagerSpec extends Specification { + + @TempDir + Path serviceRoot + + DependencyManager manager = new DependencyManager() + + FractalModule module = FractalModule.builder() + .serviceName("user-service") + .packageName("com.budgetapp.user") + .port(8082) + .build() + + private static final String BASE_POM = """\ + + + + + org.springframework.boot + spring-boot-starter-web + + + +""" + + private void writePom(String content = BASE_POM) { + Files.writeString(serviceRoot.resolve("pom.xml"), content) + } + + private void writeJavaFile(String relativePath, String content) { + Path file = serviceRoot.resolve("src/main/java").resolve(relativePath) + Files.createDirectories(file.parent) + Files.writeString(file, content) + } + + private String readPom() { + Files.readString(serviceRoot.resolve("pom.xml")) + } + + def "provisions Lombok when generated service source contains import lombok.*"() { + given: "a pom.xml without Lombok and a Java file that imports lombok.Data" + writePom() + writeJavaFile("com/budgetapp/budget/dto/CreateBudgetRequest.java", """\ +package com.budgetapp.budget.dto; + +import lombok.Data; + +@Data +public class CreateBudgetRequest { + private String name; +} +""") + + when: + manager.provisionImpliedDependencies(module, serviceRoot) + + then: + readPom().contains("org.projectlombok") + } + + def "provisions spring-boot-starter-validation when source contains import jakarta.validation.*"() { + given: "a pom.xml without validation starter and a Java file that imports jakarta.validation" + writePom() + writeJavaFile("com/budgetapp/budget/dto/CreateBudgetRequest.java", """\ +package com.budgetapp.budget.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; + +public class CreateBudgetRequest { + @NotNull + @NotBlank + private String name; +} +""") + + when: + manager.provisionImpliedDependencies(module, serviceRoot) + + then: + readPom().contains("spring-boot-starter-validation") + } + + def "does not provision Lombok when already present in pom.xml (idempotent)"() { + given: "a pom.xml that already contains Lombok and a Java file importing lombok" + String pomWithLombok = BASE_POM.replace("", """\ + + org.projectlombok + lombok + true + + """) + writePom(pomWithLombok) + writeJavaFile("com/budgetapp/user/service/UserService.java", """\ +package com.budgetapp.user.service; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class UserService {} +""") + + when: + manager.provisionImpliedDependencies(module, serviceRoot) + + then: "only one occurrence of org.projectlombok in pom.xml" + readPom().count("org.projectlombok") == 1 + } +} diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/FlywayMigrationGeneratorSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/FlywayMigrationGeneratorSpec.groovy index 810fa33..c4424b9 100644 --- a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/FlywayMigrationGeneratorSpec.groovy +++ b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/FlywayMigrationGeneratorSpec.groovy @@ -149,6 +149,53 @@ class FlywayMigrationGeneratorSpec extends Specification { "BigDecimal" | "DECIMAL(19,4)" } + def "local @ManyToMany generates a join table in the migration"() { + given: "Student entity has @ManyToMany to a local Course entity" + writeEntity("org/fractalx/test/order/Student.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import java.util.List; + @Entity + public class Student { + @Id private Long id; + @ManyToMany + private List courses; + } + """) + + when: + generator.generateMigration(module, serviceRoot) + + then: "a join table is emitted alongside the entity table" + def content = sql() + content.contains("CREATE TABLE IF NOT EXISTS student_courses") + content.contains("student_id BIGINT") + content.contains("course_id BIGINT") + } + + def "@ElementCollection List field generates an element collection table"() { + given: "Student entity has @ElementCollection List courseIds (post-decoupling)" + writeEntity("org/fractalx/test/order/Student.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import java.util.List; + @Entity + public class Student { + @Id private Long id; + @ElementCollection + private List courseIds; + } + """) + + when: + generator.generateMigration(module, serviceRoot) + + then: "an element collection table is emitted" + def content = sql() + content.contains("CREATE TABLE IF NOT EXISTS student_course_ids") + content.contains("course_ids VARCHAR(255)") + } + def "entity class name is converted to snake_case table name"() { given: writeEntity("org/fractalx/test/order/OrderLineItem.java", """ diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/ReferenceValidatorGeneratorSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/ReferenceValidatorGeneratorSpec.groovy index 78c9518..aeaeb87 100644 --- a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/ReferenceValidatorGeneratorSpec.groovy +++ b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/ReferenceValidatorGeneratorSpec.groovy @@ -157,6 +157,54 @@ class ReferenceValidatorGeneratorSpec extends Specification { Files.exists(validationDir().resolve("CustomerExistsClient.java")) } + def "detects @ElementCollection List courseIds and generates validateAllCourseExist"() { + given: "entity with @ElementCollection List courseIds (produced by ManyToMany decoupling)" + writeEntity("org/fractalx/test/order/Order.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import java.util.List; + @Entity + public class Order { + @Id private Long id; + @ElementCollection + private List courseIds; + } + """) + + when: + generator.generateReferenceValidator(moduleWithDeps(), serviceRoot) + + then: + def content = Files.readString(validationDir().resolve("ReferenceValidator.java")) + content.contains("validateAllCourseExist") + content.contains("List") + content.contains("validateCourseExists") // collection method delegates to singular + // The ExistsClient is the same interface — singular exists() handles both + Files.exists(validationDir().resolve("CourseExistsClient.java")) + } + + def "generated ReferenceValidator includes java.util.List import when collection id fields present"() { + given: + writeEntity("org/fractalx/test/order/Order.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import java.util.List; + @Entity + public class Order { + @Id private Long id; + @ElementCollection + private List courseIds; + } + """) + + when: + generator.generateReferenceValidator(moduleWithDeps(), serviceRoot) + + then: + def content = Files.readString(validationDir().resolve("ReferenceValidator.java")) + content.contains("import java.util.List") + } + def "detects multiple decoupled Id fields and generates a validator method for each"() { given: "entity with both paymentId and productId" writeEntity("org/fractalx/test/order/Order.java", """ diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/RelationshipDecouplerSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/RelationshipDecouplerSpec.groovy index 86e77da..39fdc72 100644 --- a/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/RelationshipDecouplerSpec.groovy +++ b/fractalx-core/src/test/groovy/org/fractalx/core/datamanagement/RelationshipDecouplerSpec.groovy @@ -225,6 +225,157 @@ class RelationshipDecouplerSpec extends Specification { read("OrderItem.java").contains("Order order") } + def "@ManyToMany remote entity field is converted to @ElementCollection List courseIds"() { + given: "Student entity references remote Course via @ManyToMany" + write("Student.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import org.fractalx.test.lms.Course; + import java.util.List; + @Entity + public class Student { + @Id private Long id; + @ManyToMany + private List courses; + } + """) + + when: "Course.java is NOT in serviceRoot — it is a remote entity" + decoupler.transform(serviceRoot, module) + + then: + def result = read("Student.java") + result.contains("List") + result.contains("courseIds") + result.contains("@ElementCollection") + !result.contains("@ManyToMany") + !result.contains("List") + } + + def "@ManyToMany getter is renamed and returns List"() { + given: + write("Student.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import org.fractalx.test.lms.Course; + import java.util.List; + @Entity + public class Student { + @Id private Long id; + @ManyToMany + private List courses; + public List getCourses() { return courses; } + public void setCourses(List courses) { this.courses = courses; } + } + """) + + when: + decoupler.transform(serviceRoot, module) + + then: + def result = read("Student.java") + result.contains("getCourseIds()") + result.contains("setCourseIds(") + !result.contains("getCourses()") + !result.contains("setCourses(List") + } + + def "Lombok @Data Request class: getters inferred from fields for setter call-site rewrite"() { + given: """Order entity with @ManyToOne Payment (remote). + CreateOrderRequest uses Lombok @Data — no explicit getters in the AST. + The service passes the 'payment' method param to order.setPayment(); + after decoupling, Case B should rewrite it to order.setPaymentId(request.getPaymentId()) + using the getter inferred from the @Data-annotated field.""" + write("Order.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import org.fractalx.test.payment.Payment; + @Entity + public class Order { + @Id private Long id; + @ManyToOne + private Payment payment; + } + """) + write("CreateOrderRequest.java", """ + package org.fractalx.test.order; + import lombok.Data; + @Data + public class CreateOrderRequest { + private String paymentId; + } + """) + // The service receives a Payment param (remote entity passed in by caller) and a + // CreateOrderRequest that holds the canonical paymentId. + // order.setPayment(payment) — 'payment' is a NameExpr NOT in remoteVarToIdVar + // (it's a method param, not a locally-declared var), so Case B fires. + write("OrderService.java", """ + package org.fractalx.test.order; + import org.springframework.stereotype.Service; + import org.fractalx.test.payment.Payment; + @Service + public class OrderService { + private final OrderRepository orderRepository; + public OrderService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + public Order create(Payment payment, CreateOrderRequest request) { + Order order = new Order(); + order.setPayment(payment); + orderRepository.save(order); + return order; + } + } + """) + + when: + decoupler.transform(serviceRoot, module) + + then: "call-site rewrite uses getPaymentId() inferred from the Lombok @Data field" + def svc = read("OrderService.java") + svc.contains("getPaymentId()") + svc.contains("setPaymentId(") + } + + def "chained access on decoupled @ManyToMany collection gets DECOUPLING WARNING comment"() { + given: "Student entity references remote Course via @ManyToMany; service chains on getCourses()" + write("Student.java", """ + package org.fractalx.test.order; + import jakarta.persistence.*; + import org.fractalx.test.lms.Course; + import java.util.List; + @Entity + public class Student { + @Id private Long id; + @ManyToMany + private List courses; + } + """) + write("StudentService.java", """ + package org.fractalx.test.order; + import org.springframework.stereotype.Service; + @Service + public class StudentService { + private final StudentRepository studentRepository; + public StudentService(StudentRepository studentRepository) { + this.studentRepository = studentRepository; + } + public boolean hasMathCourse(Student student) { + return student.getCourses().stream().anyMatch(c -> c.getTitle().equals("Math")); + } + } + """) + + when: + decoupler.transform(serviceRoot, module) + + then: "getCourses() renamed to getCourseIds() and a DECOUPLING WARNING comment is added" + def svc = read("StudentService.java") + svc.contains("getCourseIds()") + svc.contains("DECOUPLING WARNING") + !svc.contains("getCourses()") + } + def "transformation is a no-op when there are no remote entity references"() { given: "simple entity with no cross-module relationships" def original = """ diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/gateway/GatewayOpenApiGeneratorSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/gateway/GatewayOpenApiGeneratorSpec.groovy index b86dac3..6de9ea6 100644 --- a/fractalx-core/src/test/groovy/org/fractalx/core/gateway/GatewayOpenApiGeneratorSpec.groovy +++ b/fractalx-core/src/test/groovy/org/fractalx/core/gateway/GatewayOpenApiGeneratorSpec.groovy @@ -83,8 +83,8 @@ class GatewayOpenApiGeneratorSpec extends Specification { then: def spec = openApi() - spec.contains("/api/order:") - spec.contains("/api/order/{id}:") + spec.contains("/api/orders:") + spec.contains("/api/orders/{id}:") spec.contains("operationId: \"order-service-list\"") spec.contains("operationId: \"order-service-create\"") spec.contains("operationId: \"order-service-get-by-id\"") diff --git a/fractalx-core/src/test/groovy/org/fractalx/core/generator/service/CorrelationIdGeneratorSpec.groovy b/fractalx-core/src/test/groovy/org/fractalx/core/generator/service/CorrelationIdGeneratorSpec.groovy index ed0d0b9..19e2b76 100644 --- a/fractalx-core/src/test/groovy/org/fractalx/core/generator/service/CorrelationIdGeneratorSpec.groovy +++ b/fractalx-core/src/test/groovy/org/fractalx/core/generator/service/CorrelationIdGeneratorSpec.groovy @@ -45,12 +45,53 @@ class CorrelationIdGeneratorSpec extends Specification { Files.exists(serviceRoot.resolve("src/main/resources/logback-spring.xml")) } - def "logback-spring.xml includes correlationId MDC key in log pattern"() { + def "logback-spring.xml includes correlationId MDC key with NO_CORR fallback"() { when: generator.generate(ctx()) then: - logback().contains("%X{correlationId") + logback().contains("%X{correlationId:-NO_CORR}") + } + + def "logback-spring.xml uses merged Spring Boot + correlationId pattern"() { + when: + generator.generate(ctx()) + + then: + def content = logback() + // ISO-8601 timestamp with timezone offset + content.contains("%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}") + // process ID — uses ${PID} system property (ProcessIdConverter removed in Spring Boot 3.x) + content.contains("\${PID:-????}") + // Spring Boot --- separator and app-name column + content.contains("---") + content.contains("\${APP_NAME}") + // padded thread column + content.contains("%15.15t") + // abbreviated logger padded to 40 chars + content.contains("%-40.40logger{39}") + } + + def "logback-spring.xml does not use %pid converter (removed in Spring Boot 3.x)"() { + when: + generator.generate(ctx()) + + then: + def content = logback() + // ProcessIdConverter no longer exists in Spring Boot 3 — must use ${PID} property instead + !content.contains("ProcessIdConverter") + !content.contains("%pid") + } + + def "logback-spring.xml binds spring.application.name via springProperty"() { + when: + generator.generate(ctx()) + + then: + def content = logback() + content.contains("io.micrometer micrometer-core + + + io.micrometer + micrometer-tracing + true + org.slf4j diff --git a/fractalx-runtime/src/main/java/org/fractalx/runtime/NetScopeContextInterceptor.java b/fractalx-runtime/src/main/java/org/fractalx/runtime/NetScopeContextInterceptor.java index d6db4c2..a493bd6 100644 --- a/fractalx-runtime/src/main/java/org/fractalx/runtime/NetScopeContextInterceptor.java +++ b/fractalx-runtime/src/main/java/org/fractalx/runtime/NetScopeContextInterceptor.java @@ -1,9 +1,11 @@ package org.fractalx.runtime; import io.grpc.*; +import io.micrometer.tracing.Tracer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.stereotype.Component; @@ -26,6 +28,9 @@ public class NetScopeContextInterceptor implements ClientInterceptor, ServerInte private static final Metadata.Key CORRELATION_METADATA_KEY = Metadata.Key.of("x-correlation-id", Metadata.ASCII_STRING_MARSHALLER); + @Autowired(required = false) + private Tracer tracer; + // ---- Client side: inject correlationId into outgoing gRPC metadata ---- @Override @@ -70,6 +75,13 @@ public ServerCall.Listener interceptCall( // For unary calls, invokeMethod() is triggered inside onHalfClose(). return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(delegate) { + private void tagCurrentSpan(String cid) { + if (tracer != null) { + io.micrometer.tracing.Span span = tracer.currentSpan(); + if (span != null) span.tag(CORRELATION_ID_KEY, cid); + } + } + @Override public void onMessage(ReqT message) { MDC.put(CORRELATION_ID_KEY, cid); @@ -83,6 +95,7 @@ public void onMessage(ReqT message) { @Override public void onHalfClose() { MDC.put(CORRELATION_ID_KEY, cid); + tagCurrentSpan(cid); try { super.onHalfClose(); } finally { diff --git a/fractalx-runtime/src/main/java/org/fractalx/runtime/TraceFilter.java b/fractalx-runtime/src/main/java/org/fractalx/runtime/TraceFilter.java index 44ef466..56c51cb 100644 --- a/fractalx-runtime/src/main/java/org/fractalx/runtime/TraceFilter.java +++ b/fractalx-runtime/src/main/java/org/fractalx/runtime/TraceFilter.java @@ -1,11 +1,14 @@ package org.fractalx.runtime; +import io.micrometer.tracing.Tracer; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import java.io.IOException; @@ -22,6 +25,11 @@ public class TraceFilter implements Filter { private static final String CORRELATION_ID_HEADER = "X-Correlation-Id"; private static final String CORRELATION_ID_KEY = "correlationId"; + /** Injected when micrometer-tracing is on the classpath (Spring Boot 3 default). Null-safe. */ + @Nullable + @Autowired(required = false) + private Tracer tracer; + @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { @@ -34,8 +42,15 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha try { MDC.put(CORRELATION_ID_KEY, correlationId); - // Also add to response header for visibility + // Echo back to caller so they can track the ID httpResponse.setHeader(CORRELATION_ID_HEADER, correlationId); + // Tag the active Micrometer/OTel span so Jaeger can index and search by correlationId + if (tracer != null) { + io.micrometer.tracing.Span span = tracer.currentSpan(); + if (span != null) { + span.tag("correlationId", correlationId); + } + } chain.doFilter(request, response); } finally { MDC.remove(CORRELATION_ID_KEY);