Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,16 @@ private Map<String, FractalxConfig.ServiceOverride> readServiceOverrides(Map<Str
if (enabledVal instanceof Boolean b) tracingEnabled = b;
else if ("false".equalsIgnoreCase(String.valueOf(enabledVal))) tracingEnabled = false;
}
result.put(name, new FractalxConfig.ServiceOverride(port, tracingEnabled));
String dsUrl = null, dsUsername = null, dsPassword = null, dsDriver = null;
Object dsObj = svc.get("datasource");
if (dsObj instanceof 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}.
*
* <p>Currently detects:
* <ul>
* <li>Lombok ({@code import lombok.*}) → {@code org.projectlombok:lombok}</li>
* <li>Jakarta Validation ({@code import jakarta.validation.*}) →
* {@code spring-boot-starter-validation}</li>
* </ul>
*/
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<Path> 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 = """
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>""";
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 = """
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>""";
injectDependency(module, serviceRoot, "spring-boot-starter-validation", validationXml);
log.info(" ✓ Provisioned spring-boot-starter-validation for {}", module.getServiceName());
}
}

/**
* Injects Lombok into the {@code <annotationProcessorPaths>} 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).
*
* <p>{@code annotationProcessorPaths} does NOT resolve versions from
* {@code <dependencyManagement>}, 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 </configuration> that closes the compiler plugin's <configuration> block
int configEnd = content.indexOf("</configuration>", compilerIdx);
if (configEnd == -1) return;

String lombokVersion = resolveLombokVersion(content);

String processorBlock = """

<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>""" + lombokVersion + """
</version>
</path>
</annotationProcessorPaths>""";

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 = "<spring-boot.version>";
int start = pomContent.indexOf(marker);
if (start != -1) {
int end = pomContent.indexOf("</spring-boot.version>", 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand All @@ -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<String> 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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

/**
Expand Down Expand Up @@ -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<String> 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()),
Expand Down Expand Up @@ -158,6 +190,11 @@ private String buildMigrationScript(FractalModule module, List<EntityInfo> 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
Expand Down Expand Up @@ -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<Course>} → {@code Optional.of("Course")}.
*/
private Optional<String> 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<Type> 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("^_", "");
}
Expand All @@ -242,7 +294,9 @@ private String toSqlType(String javaType) {
private static class EntityInfo {
String className;
String tableName;
final List<ColumnInfo> fields = new ArrayList<>();
final List<ColumnInfo> fields = new ArrayList<>();
/** DDL for join tables (@ManyToMany) and element-collection tables (@ElementCollection). */
final List<String> extraTableDdl = new ArrayList<>();
}

private static class ColumnInfo {
Expand Down
Loading