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
6 changes: 4 additions & 2 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 @@ -16,6 +16,7 @@

import org.eclipse.dirigible.components.base.helpers.JsonHelper;
import org.eclipse.dirigible.components.intent.generator.ProcessResolverSupport.Resolver;
import org.eclipse.dirigible.components.intent.generator.SetFieldSupport.Setter;
import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.InboundIntent;
import org.eclipse.dirigible.components.intent.model.IntegrationIntent;
Expand Down Expand Up @@ -71,32 +72,34 @@ public void generate(IntentGenerationContext context) {
IntentSettings settings = context.getSettings();
List<Map<String, Object>> triggers = buildTriggers(model, byName, compositionParents, settings);
List<Map<String, Object>> resolvers = buildResolvers(model, settings);
List<Map<String, Object>> setters = buildSetters(model, settings);
List<Map<String, Object>> notifications = buildNotifications(model, byName, compositionParents, settings);
List<Map<String, Object>> schedules = buildSchedules(model, byName, compositionParents, settings);
List<Map<String, Object>> integrations = buildIntegrations(model, byName, compositionParents, settings);
List<Map<String, Object>> inbound = buildInbound(model, byName, compositionParents, settings);
List<Map<String, Object>> rollups = buildRollups(model, byName, compositionParents, settings);

if (triggers.isEmpty() && resolvers.isEmpty() && notifications.isEmpty() && schedules.isEmpty() && integrations.isEmpty()
&& inbound.isEmpty() && rollups.isEmpty()) {
if (triggers.isEmpty() && resolvers.isEmpty() && setters.isEmpty() && notifications.isEmpty() && schedules.isEmpty()
&& integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty()) {
// No process glue for this intent - any stale .glue is removed by the post-pass scrub.
return;
}

Map<String, Object> glue = new LinkedHashMap<>();
glue.put("triggers", triggers);
glue.put("resolvers", resolvers);
glue.put("setters", setters);
glue.put("notifications", notifications);
glue.put("schedules", schedules);
glue.put("integrations", integrations);
glue.put("inbound", inbound);
glue.put("rollups", rollups);
context.writeModelFile(IntentNaming.baseName(context) + ".glue", JsonHelper.toJson(glue));
LOGGER.debug(
"Wrote glue with [{}] trigger(s), [{}] resolver(s), [{}] notification(s), [{}] schedule(s), [{}] integration(s),"
+ " [{}] inbound webhook(s) and [{}] rollup(s)",
triggers.size(), resolvers.size(), notifications.size(), schedules.size(), integrations.size(), inbound.size(),
rollups.size());
"Wrote glue with [{}] trigger(s), [{}] resolver(s), [{}] setter(s), [{}] notification(s), [{}] schedule(s),"
+ " [{}] integration(s), [{}] inbound webhook(s) and [{}] rollup(s)",
triggers.size(), resolvers.size(), setters.size(), notifications.size(), schedules.size(), integrations.size(),
inbound.size(), rollups.size());
}

private static List<Map<String, Object>> buildTriggers(IntentModel model, Map<String, EntityIntent> byName,
Expand Down Expand Up @@ -360,4 +363,25 @@ private static List<Map<String, Object>> buildResolvers(IntentModel model, Inten
}
return resolvers;
}

private static List<Map<String, Object>> buildSetters(IntentModel model, IntentSettings settings) {
List<Map<String, Object>> setters = new ArrayList<>();
for (Setter setter : SetFieldSupport.setters(model)) {
if (!settings.shouldGenerate("setters", setter.className())) {
LOGGER.info("Settings opt-out: keeping existing handler for setter [{}] (not generated)", setter.className());
continue;
}
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("process", setter.process());
entry.put("className", setter.className());
entry.put("entity", setter.entity());
entry.put("perspective", setter.perspective());
entry.put("keyProperty", setter.keyProperty());
entry.put("keyAccessor", setter.keyAccessor());
entry.put("field", setter.field());
entry.put("value", setter.value());
setters.add(entry);
}
return setters;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ public static IntentSettings scaffold(IntentModel model) {
for (Resolver resolver : ProcessResolverSupport.resolvers(model)) {
resolvers.put(resolver.handler(), new ArtefactOverride(true));
}
Map<String, ArtefactOverride> setters = new LinkedHashMap<>();
for (SetFieldSupport.Setter setter : SetFieldSupport.setters(model)) {
setters.put(setter.className(), new ArtefactOverride(true));
}
Map<String, ArtefactOverride> forms = new LinkedHashMap<>();
for (FormIntent form : model.getForms()) {
if (form.getName() != null) {
Expand All @@ -168,6 +172,9 @@ public static IntentSettings scaffold(IntentModel model) {
if (!resolvers.isEmpty()) {
settings.overrides.put("resolvers", resolvers);
}
if (!setters.isEmpty()) {
settings.overrides.put("setters", setters);
}
if (!forms.isEmpty()) {
settings.overrides.put("forms", forms);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import org.eclipse.dirigible.components.intent.model.EntityIntent;
import org.eclipse.dirigible.components.intent.model.FieldIntent;
import org.eclipse.dirigible.components.intent.model.FormIntent;
import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.model.ProcessIntent;
import org.eclipse.dirigible.components.intent.model.RelationIntent;
Expand All @@ -27,15 +28,25 @@
import org.slf4j.LoggerFactory;

/**
* Reads the {@code relation.field} references in a process's decision conditions and turns each
* into a <b>resolver</b>: a just-before-the-decision step that loads the related (to-one) entity by
* its FK id and exposes the wanted field as a process variable.
* Reads the {@code relation.field} references in a process's <b>decision conditions</b> and
* <b>user-task forms</b> and turns each into a <b>resolver</b>: a step inserted just before the
* first step that needs it, which loads the related (to-one) entity by its FK id and exposes the
* wanted field as a process variable.
* <p>
* A decision authored as {@code if: "book.price > 500"} on a {@code LoanApproval} triggered by
* {@code onCreate: Loan} resolves {@code book} (a to-one relation on {@code Loan}) to {@code Book},
* reads {@code price}, and publishes it as the {@code book_price} variable; the condition is then
* rewritten to {@code book_price > 500}. Fetching at the decision (rather than eagerly at process
* start) keeps the value fresh for long-running approvals and keeps the process variables lean.
* rewritten to {@code book_price > 500}. A user-task form that lists {@code book.price} among its
* {@code fields} drives the same resolver, and the form control binds to the {@code book_price}
* variable so the value is shown to the reviewer (this is why the form needs the resolver: the form
* model is the process variables, which carry the {@code Book} FK id but not the book's own
* fields).
* <p>
* A resolver is anchored at the <b>earliest</b> step that references it (the steps are scanned in
* declaration order and each {@code relation.field} is recorded once per process). The variable it
* sets persists for the rest of the process, so a single resolver before the first user-task form
* also serves a later decision on the same path - which is the whole point: fetching it before the
* form rather than only before the decision is what makes the form field non-empty.
* <p>
* Scope: one-hop to-one relations of the process's trigger entity (the entity whose fields seed the
* process variables). Cross-process / multi-hop paths are out of scope and ignored with a warning.
Expand All @@ -53,7 +64,8 @@ private ProcessResolverSupport() {}
* One resolver to generate.
*
* @param process the owning process
* @param decisionStep the decision step whose condition referenced the path
* @param beforeStep the step before which the resolver task is inserted (the earliest decision or
* user-task that referenced the path)
* @param token the authored {@code relation.field} text (e.g. {@code book.price})
* @param variable the process variable the resolver sets (e.g. {@code book_price})
* @param handler the generated handler class simple name (e.g. {@code ResolveBookPrice})
Expand All @@ -64,7 +76,7 @@ private ProcessResolverSupport() {}
* @param targetIdAccessor the {@link Number} accessor matching the target PK type ({@code intValue}
* / {@code longValue})
*/
public record Resolver(String process, String decisionStep, String token, String variable, String handler, String fkProperty,
public record Resolver(String process, String beforeStep, String token, String variable, String handler, String fkProperty,
String targetEntity, String targetField, String targetPerspective, String targetIdAccessor) {
}

Expand All @@ -73,52 +85,87 @@ public static List<Resolver> resolvers(IntentModel model) {
List<Resolver> resolvers = new ArrayList<>();
Map<String, EntityIntent> byName = IntentEntities.byName(model);
Map<String, String> compositionParents = IntentEntities.compositionParents(model);
Map<String, FormIntent> formsByName = formsByName(model);
Set<String> seen = new LinkedHashSet<>();
for (ProcessIntent process : model.getProcesses()) {
String triggerEntity = TriggerSupport.triggerEntity(process);
EntityIntent owner = triggerEntity == null ? null : byName.get(triggerEntity);
if (owner == null) {
continue; // no trigger entity -> no process-variable context to resolve against
}
// Scan steps in declaration order so each relation.field is anchored at the FIRST step that
// needs it (the `seen` set keeps the earliest); decision conditions and user-task forms both
// contribute, and the variable persists across the process so one resolver serves both.
for (StepIntent step : process.getSteps()) {
if (!"decision".equals(step.getKind())) {
continue;
}
String condition = stringArg(step, "if");
if (condition == null) {
continue;
if ("decision".equals(step.getKind())) {
String condition = stringArg(step, "if");
if (condition != null) {
collectFromCondition(byName, compositionParents, process, owner, step, condition, resolvers, seen);
}
} else if ("userTask".equals(step.getKind())) {
FormIntent form = formsByName.get(stringArg(step, "form"));
if (form != null) {
collectFromForm(byName, compositionParents, process, owner, step, form, resolvers, seen);
}
}
collect(model, byName, compositionParents, process, owner, step, condition, resolvers, seen);
}
}
return resolvers;
}

private static void collect(IntentModel model, Map<String, EntityIntent> byName, Map<String, String> compositionParents,
private static Map<String, FormIntent> formsByName(IntentModel model) {
Map<String, FormIntent> index = new java.util.HashMap<>();
for (FormIntent form : model.getForms()) {
if (form.getName() != null) {
index.put(form.getName(), form);
}
}
return index;
}

private static void collectFromCondition(Map<String, EntityIntent> byName, Map<String, String> compositionParents,
ProcessIntent process, EntityIntent owner, StepIntent step, String condition, List<Resolver> resolvers, Set<String> seen) {
Matcher matcher = RELATION_FIELD.matcher(condition);
while (matcher.find()) {
String relationName = matcher.group(1);
String fieldName = matcher.group(2);
RelationIntent relation = toOneRelation(owner, relationName);
if (relation == null) {
continue; // not a to-one relation of the trigger entity - leave the token alone
}
EntityIntent target = byName.get(relation.getTo());
if (target == null || fieldOf(target, fieldName) == null) {
LOGGER.warn("Decision [{}] in process [{}] references [{}] but [{}] has no field [{}] - skipping resolver", step.getName(),
process.getName(), matcher.group(), relation.getTo(), fieldName);
addResolver(byName, compositionParents, process, owner, step, matcher.group(1), matcher.group(2), "decision", resolvers, seen);
}
}

private static void collectFromForm(Map<String, EntityIntent> byName, Map<String, String> compositionParents, ProcessIntent process,
EntityIntent owner, StepIntent step, FormIntent form, List<Resolver> resolvers, Set<String> seen) {
for (String field : form.getFields()) {
if (field == null) {
continue;
}
String handler = "Resolve" + IntentNaming.pascalCase(relationName) + IntentNaming.pascalCase(fieldName);
if (!seen.add(process.getName() + "/" + handler)) {
continue; // same resolution referenced twice in the process
int dot = field.indexOf('.');
if (dot <= 0 || dot >= field.length() - 1) {
continue; // a plain field of the bound entity - no resolver needed
}
resolvers.add(new Resolver(process.getName(), step.getName(), relationName + "." + fieldName, relationName + "_" + fieldName,
handler, IntentNaming.pascalCase(relationName), relation.getTo(), IntentNaming.pascalCase(fieldName),
IntentEntities.resolvePerspective(relation.getTo(), compositionParents),
idAccessor(IntentEntities.primaryKeyOf(target))));
addResolver(byName, compositionParents, process, owner, step, field.substring(0, dot), field.substring(dot + 1),
"user-task form", resolvers, seen);
}
}

private static void addResolver(Map<String, EntityIntent> byName, Map<String, String> compositionParents, ProcessIntent process,
EntityIntent owner, StepIntent step, String relationName, String fieldName, String origin, List<Resolver> resolvers,
Set<String> seen) {
RelationIntent relation = toOneRelation(owner, relationName);
if (relation == null) {
return; // not a to-one relation of the trigger entity - leave the token alone
}
EntityIntent target = byName.get(relation.getTo());
if (target == null || fieldOf(target, fieldName) == null) {
LOGGER.warn("{} [{}] in process [{}] references [{}.{}] but [{}] has no field [{}] - skipping resolver", origin, step.getName(),
process.getName(), relationName, fieldName, relation.getTo(), fieldName);
return;
}
String handler = "Resolve" + IntentNaming.pascalCase(relationName) + IntentNaming.pascalCase(fieldName);
if (!seen.add(process.getName() + "/" + handler)) {
return; // same resolution already anchored at an earlier step in this process
}
resolvers.add(new Resolver(process.getName(), step.getName(), relationName + "." + fieldName, relationName + "_" + fieldName,
handler, IntentNaming.pascalCase(relationName), relation.getTo(), IntentNaming.pascalCase(fieldName),
IntentEntities.resolvePerspective(relation.getTo(), compositionParents), idAccessor(IntentEntities.primaryKeyOf(target))));
}

private static RelationIntent toOneRelation(EntityIntent owner, String name) {
Expand Down
Loading
Loading