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
9 changes: 5 additions & 4 deletions components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5035,7 +5035,6 @@ private static List<Map<String, Object>> buildResolvers(IntentModel model, Inten
// same registry-wide-compile mechanism a notify recipient's relation load uses.
entry.put("crossModel", resolver.crossModel());
entry.put("targetModel", resolver.targetModel());
entry.put("targetProject", resolver.targetProject());
resolvers.add(entry);
}
return resolvers;
Expand All @@ -5055,8 +5054,8 @@ private static ProcessResolverSupport.CrossModelLookup resolverCrossModelLookup(
return null;
}
CrossModelSupport.TargetInfo target = CrossModelSupport.resolve(context, uses, relation.getTo());
return new ProcessResolverSupport.CrossModelTarget(target.perspectiveName(), uses.resolveProject(), uses.getModel(),
target.propertyNames(), target.fkType());
return new ProcessResolverSupport.CrossModelTarget(target.perspectiveName(), uses.getModel(), target.propertyNames(),
target.fkType());
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import org.eclipse.dirigible.components.intent.LoggedValue;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.repository.api.IRepository;
import org.eclipse.dirigible.repository.api.IResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Per-generation call context handed to every {@link IntentTargetGenerator}. Carries the parsed
Expand All @@ -36,9 +41,19 @@
* All writes go through {@link #writeModelFile(String, String)}, which records the emitted file
* names so {@link IntentGenerationService} can scrub files that a previous generation wrote but the
* current one no longer produces.
*
* <p>
* Every write also journals the state it replaced, so a pass that is REFUSED - a generator raising
* {@link org.eclipse.dirigible.components.intent.parser.IntentValidationException} - can be undone
* whole by {@link #rollbackWrittenFiles()} (dirigible #7227). Generation runs the generators in
* {@code @Order}, so a check placed in a late generator would otherwise leave the earlier ones'
* output in the workspace next to the 422: an authoring mistake refused at generation must cost the
* developer nothing, exactly as one refused at parse does.
*/
public final class IntentGenerationContext {

private static final Logger LOGGER = LoggerFactory.getLogger(IntentGenerationContext.class);

/** Repository path of the target project root, e.g. {@code /users/admin/workspace/my-library}. */
private final String projectRoot;

Expand Down Expand Up @@ -83,6 +98,14 @@ public final class IntentGenerationContext {
/** Bare file names written under {@link #projectRoot} during this generation pass. */
private final Set<String> writtenFileNames = new LinkedHashSet<>();

/**
* What this pass actually CHANGED, keyed by bare file name: the content the file held before the
* pass touched it, or {@code null} when the pass created it. Recorded on the first change of each
* file only, and never for a write that turned out to be byte-identical (there is nothing to undo)
* - so it is exactly the set {@link #rollbackWrittenFiles()} has to put back.
*/
private final Map<String, byte[]> replacedContent = new LinkedHashMap<>();

/**
* Non-fatal generation issues (e.g. a piece of glue that could not be emitted because a reference
* did not resolve) collected during the pass. Surfaced in the generate response so the drop is not
Expand Down Expand Up @@ -140,10 +163,13 @@ public void writeModelFile(String fileName, String content) {
byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
IResource existing = repository.getResource(path);
if (existing.exists()) {
if (!Arrays.equals(existing.getContent(), bytes)) {
byte[] previous = existing.getContent();
if (!Arrays.equals(previous, bytes)) {
journal(fileName, previous);
existing.setContent(bytes);
}
} else {
journal(fileName, null);
repository.createResource(path, bytes);
}
writtenFileNames.add(fileName);
Expand All @@ -168,11 +194,55 @@ public void writeModelFileIfAbsent(String fileName, String content) {
String path = projectRoot + "/" + fileName;
IResource existing = repository.getResource(path);
if (!existing.exists()) {
journal(fileName, null);
repository.createResource(path, content.getBytes(StandardCharsets.UTF_8));
}
writtenFileNames.add(fileName);
}

/**
* Record the state a file held before this pass first changed it - {@code null} meaning it did not
* exist. Only the FIRST change of a file is journaled: the rollback has to restore the state the
* pass started from, not the one an earlier generator of the same pass left behind.
*/
private void journal(String fileName, byte[] previous) {
if (!replacedContent.containsKey(fileName)) {
replacedContent.put(fileName, previous);
}
}

/**
* Undo every change this pass made at the project root: a file it created is removed, a file it
* overwrote gets its previous content back. Used when the pass is refused as a whole - a generator
* raising {@link org.eclipse.dirigible.components.intent.parser.IntentValidationException} - so the
* 422 leaves the workspace exactly as the developer had it (dirigible #7227), rather than the
* partial model set the generators before the failing one had already written.
*
* <p>
* A restore that itself fails is logged and the rest still run: the caller is on its way to
* reporting the authoring error, and one file that could not be put back must not hide it.
*/
void rollbackWrittenFiles() {
if (dryRun || repository == null || projectRoot == null) {
return;
}
for (Map.Entry<String, byte[]> entry : replacedContent.entrySet()) {
String path = projectRoot + "/" + entry.getKey();
try {
if (entry.getValue() == null) {
repository.removeResource(path);
} else {
repository.getResource(path)
.setContent(entry.getValue());
}
} catch (RuntimeException e) {
LOGGER.error("Failed to roll back intent output [{}]", LoggedValue.of(path), e);
}
}
replacedContent.clear();
writtenFileNames.clear();
}

/**
* Claim an already-present, developer-owned model file: it is neither written nor scrubbed by this
* pass. This is the write-once counterpart for a generator that cannot always produce content — it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@
* {@code .intent} file itself, code files, and the {@code gen/} / {@code custom/} subfolders (only
* direct child resources are considered). Removing a process / form / report / seed from the intent
* therefore removes its model file on the next Generate instead of leaving a stale artefact around.
*
* <p>
* A pass that is REFUSED writes nothing: an {@link IntentValidationException} out of any generator
* rolls back what the earlier ones already wrote before it leaves as a 422 (dirigible #7227). The
* generators run in {@code @Order}, so without that a check placed in a late generator would leave
* a half-generated model set in the workspace - e.g. the {@code .edm}/{@code .model} and a
* {@code .bpmn} carrying a {@code Resolve<...>} service task whose handler the refused glue pass
* never generated. Refusing at generation must cost the developer no more than refusing at parse.
*/
@Component
public class IntentGenerationService {
Expand Down Expand Up @@ -144,6 +152,12 @@ public GenerationResult generate(String yaml, String projectRoot, String project
} catch (IntentValidationException e) {
// A fatal authoring error the developer must fix (e.g. an unresolvable cross-model
// dependency) - surface it to the caller (-> 422), do NOT isolate it like a generator bug.
// The pass is refused AS A WHOLE (dirigible #7227): the generators run in @Order, so a
// check in a later one would otherwise leave the earlier ones' output behind - a
// half-generated model set next to the 422, and nothing scrubs it (the scrub below is
// never reached). Undo this pass's writes so the workspace is exactly what it was, the
// way a parse-time refusal leaves it.
context.rollbackWrittenFiles();
throw e;
} catch (RuntimeException e) {
LOGGER.error("Intent generator [{}] failed for project [{}]", generator.name(), LoggedValue.of(projectName), e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,12 @@ public interface CrossModelLookup {
* accessor).
*
* @param perspectiveName the target's perspective in the owner model (its gen data subfolder)
* @param project the owner project
* @param modelAlias the owner model alias
* @param propertyNames the target's PascalCase property names, or null when the owner model was
* resolved by naming convention only - the field is then trusted as authored
* @param keyType the JDBC type of the target's primary key (e.g. {@code INTEGER} / {@code BIGINT})
*/
public record CrossModelTarget(String perspectiveName, String project, String modelAlias, Set<String> propertyNames, String keyType) {
public record CrossModelTarget(String perspectiveName, String modelAlias, Set<String> propertyNames, String keyType) {
}

/**
Expand All @@ -122,12 +121,10 @@ public record CrossModelTarget(String perspectiveName, String project, String mo
* @param crossModel whether the target is owned by another model - then the generated resolver
* imports the OWNER's generated Entity/Repository package instead of this project's
* @param targetModel the owner model alias (empty for a same-model target)
* @param targetProject the owner project (empty for a same-model target)
*/
public record Resolver(String process, String beforeStep, String token, String variable, String handler, String fkProperty,
String targetEntity, String targetField, String targetPerspective, String targetIdAccessor, String ownerEntity,
String ownerPerspective, String ownerKeyProperty, String ownerKeyAccessor, boolean crossModel, String targetModel,
String targetProject) {
String ownerPerspective, String ownerKeyProperty, String ownerKeyAccessor, boolean crossModel, String targetModel) {
}

/**
Expand Down Expand Up @@ -236,11 +233,11 @@ private static void addResolver(Map<String, EntityIntent> byName, Map<String, St
handler, IntentNaming.pascalCase(relationName), relation.getTo(), IntentNaming.pascalCase(fieldName),
resolved.perspective(), resolved.idAccessor(), owner.getName(),
IntentEntities.resolvePerspective(owner.getName(), compositionParents, settingEntities), IntentEntities.keyFieldName(owner),
idAccessor(IntentEntities.primaryKeyOf(owner)), resolved.crossModel(), resolved.model(), resolved.project()));
idAccessor(IntentEntities.primaryKeyOf(owner)), resolved.crossModel(), resolved.model()));
}

/** Where the target's generated Entity/Repository live, and how its key is read off the FK. */
private record Target(String perspective, String idAccessor, boolean crossModel, String model, String project) {
private record Target(String perspective, String idAccessor, boolean crossModel, String model) {
}

/**
Expand All @@ -258,7 +255,7 @@ private static Target localTarget(Map<String, EntityIntent> byName, Map<String,
return null;
}
return new Target(IntentEntities.resolvePerspective(relation.getTo(), compositionParents, settingEntities),
idAccessor(IntentEntities.primaryKeyOf(target)), false, "", "");
idAccessor(IntentEntities.primaryKeyOf(target)), false, "");
}

/**
Expand All @@ -272,7 +269,7 @@ private static Target crossModelTarget(RelationIntent relation, String fieldName
if (target == null) {
// No lookup (a unit test, the settings scaffold): the naming convention is the target, which
// is what every other cross-model consumer falls back to when it cannot read the owner model.
return new Target(relation.getTo(), "intValue", true, relation.getModel() == null ? "" : relation.getModel(), "");
return new Target(relation.getTo(), "intValue", true, relation.getModel() == null ? "" : relation.getModel());
}
String pascalField = IntentNaming.pascalCase(fieldName);
if (target.propertyNames() != null && !target.propertyNames()
Expand All @@ -282,7 +279,7 @@ private static Target crossModelTarget(RelationIntent relation, String fieldName
+ target.modelAlias() + "] - that model declares " + new TreeSet<>(target.propertyNames())));
}
return new Target(target.perspectiveName(), "BIGINT".equalsIgnoreCase(target.keyType()) ? "longValue" : "intValue", true,
target.modelAlias() == null ? "" : target.modelAlias(), target.project() == null ? "" : target.project());
target.modelAlias() == null ? "" : target.modelAlias());
}

private static RelationIntent toOneRelation(EntityIntent owner, String name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
package org.eclipse.dirigible.components.intent.generator;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
Expand Down Expand Up @@ -102,7 +103,10 @@ void theResolverImportsTheOwnerModelsPackage() {
assertEquals("Customer", resolver.get("targetPerspective"));
assertEquals(Boolean.TRUE, resolver.get("crossModel"));
assertEquals("customers", resolver.get("targetModel"));
assertEquals("customers", resolver.get("targetProject"));
// The owner PROJECT is deliberately absent: the generated resolver imports the owner's
// Entity/Repository from its generation folder (derived from targetModel) and builds no URL,
// so a project name would be a descriptor key nothing downstream reads (dirigible #7227).
assertFalse(resolver.containsKey("targetProject"), "the resolver descriptor must carry no unread key, got: " + resolver);
assertEquals("intValue", resolver.get("targetIdAccessor"));
}

Expand Down Expand Up @@ -136,7 +140,7 @@ void aLocalRelationFieldCarriesNoCrossModelCoordinates() {
.get(0);
assertEquals(Boolean.FALSE, resolver.get("crossModel"));
assertEquals("", resolver.get("targetModel"), "a local target must leave the model empty so the local gen folder is used");
assertEquals("", resolver.get("targetProject"));
assertFalse(resolver.containsKey("targetProject"));
}

/**
Expand Down
Loading
Loading