From eaebde59053cfe01adc62ca13cc463b21ac7a41f Mon Sep 17 00:00:00 2001 From: delchev Date: Wed, 22 Jul 2026 19:37:25 +0300 Subject: [PATCH] feat(intent): resolve cross-model notify recipients + surface dropped glue (#6360) A `schedules.notify` / `notifications` recipient (`to`) or `{placeholder}` that is a one-hop `relation.field` could only reference a LOCAL entity: for a cross-model relation the target was looked up in the model's own byName map, resolved to null, and the whole notification/schedule was silently skipped with only a server-side LOGGER.warn. Cross-model to-one relations are first-class everywhere else (dropdowns, generate targets resolve through CrossModelSupport), so a recipient like `Partner.email` where Partner is owned by another `uses` model should resolve the same way. Two changes: 1. Cross-model recipient resolution. NotificationSupport gains an injected CrossModelLookup (kept Spring/IO-free; the caller's lambda does the IO via CrossModelSupport) so a cross-model `relation.field` resolves against the owner model, validates the field against the owner's properties, and records a RelationLoad flagged crossModel with the owner alias/project. The generated listener/job then imports the OWNER's gen Entity/Repository (the same registry-wide-compile mechanism the relation links / personal assignee already use) - Job.java.template / Notification.java.template import from ${load.javaGenFolder}, which generateUtils picks from the owner alias for a cross-model load and this project's folder otherwise. 2. Dropped glue is no longer silent at the API level. A recipient that cannot be resolved is recorded on the generation context and returned in the generate response under "warnings" (the generation still succeeds), in addition to the log line - so an author sees that a notification/schedule was NOT emitted. Verified end-to-end on a running instance with a two-model fixture (owner Partner{email}, consumer Deal with a cross-model Partner relation): the emitted DueDealsJob / DealCreatedNotification import gen..data.partner.Partner* and read Partner.Email; the client-Java batch compiled and registered the job + listener (0 javac errors); a bogus recipient surfaced in the generate response "warnings" and its schedule was dropped. Unit coverage added in NotificationSupportTest (cross-model resolve, owner-field validation, no-lookup fallback). Co-Authored-By: Claude Opus 4.8 --- .../intent/endpoint/IntentEndpoint.java | 5 +- .../intent/generator/GlueIntentGenerator.java | 63 ++++++++++++--- .../generator/IntentGenerationContext.java | 25 ++++++ .../generator/IntentGenerationService.java | 7 +- .../intent/generator/NotificationSupport.java | 80 +++++++++++++++++-- .../main/resources/intent-assistant-guide.md | 9 ++- .../generator/NotificationSupportTest.java | 51 ++++++++++++ .../events/Job.java.template | 4 +- .../events/Notification.java.template | 4 +- .../template/generateUtils.js | 8 +- 10 files changed, 228 insertions(+), 28 deletions(-) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java index 777c6caa6ac..003f064ab71 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java @@ -108,8 +108,11 @@ public ResponseEntity generate(@RequestParam("workspace") String workspace, @ try { IntentGenerationService.GenerationResult result = generationService.generate(yaml, projectObject.getPath(), project, workspace, baseName(path)); + // "warnings" carries non-fatal dropped glue (e.g. an unresolvable notify recipient): the + // generation succeeded, but the caller must be able to see what was NOT emitted rather than + // it living only in a server log line (dirigible #6360). return ResponseEntity.ok(Map.of("workspace", workspace, "project", project, "written", result.written(), "scrubbed", - result.scrubbed(), "codeGenerations", result.codeGenerations())); + result.scrubbed(), "codeGenerations", result.codeGenerations(), "warnings", result.issues())); } catch (IntentValidationException e) { return ResponseEntity.unprocessableEntity() .body(Map.of("issues", e.getIssues())); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 7fca32c22b2..6f14b5c5683 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -89,7 +89,7 @@ public void generate(IntentGenerationContext context) { List> aborts = buildAborts(model, settings); List> writers = buildWriters(model, settings); List> setters = buildSetters(model, settings); - List> notifications = buildNotifications(model, byName, compositionParents, settings); + List> notifications = buildNotifications(model, byName, compositionParents, settings, context); List> schedules = buildSchedules(model, byName, compositionParents, settings, context); List> integrations = buildIntegrations(model, byName, compositionParents, settings); List> inbound = buildInbound(model, byName, compositionParents, settings); @@ -353,8 +353,9 @@ private static UsesIntent findUses(IntentModel model, String alias) { } private static List> buildNotifications(IntentModel model, Map byName, - Map compositionParents, IntentSettings settings) { + Map compositionParents, IntentSettings settings, IntentGenerationContext context) { List> notifications = new ArrayList<>(); + NotificationSupport.CrossModelLookup lookup = crossModelLookup(model, context); for (NotificationIntent notification : model.getNotifications()) { if (notification.getName() == null || notification.getName() .isBlank()) { @@ -368,10 +369,10 @@ private static List> buildNotifications(IntentModel model, M LOGGER.info("Settings opt-out: keeping existing listener for notification [{}] (not generated)", notification.getName()); continue; } - NotificationSupport.Plan plan = NotificationSupport.plan(notification, byName.get(entity), byName, compositionParents); + NotificationSupport.Plan plan = NotificationSupport.plan(notification, byName.get(entity), byName, compositionParents, lookup); if (plan == null) { - LOGGER.warn("Notification [{}] recipient [{}] is not a resolvable field or relation.field of [{}] - skipping", - notification.getName(), notification.getTo(), entity); + reportDroppedGlue(context, "Notification [" + notification.getName() + "] recipient [" + notification.getTo() + + "] is not a resolvable field or relation.field of [" + entity + "] - the notification was NOT generated"); continue; } Map entry = new LinkedHashMap<>(); @@ -1502,13 +1503,12 @@ private static List> buildSchedules(IntentModel model, Map> buildSchedulesForTest(IntentModel model) { null); } + /** + * A cross-model relation resolver for the notify machinery: reads the owner model's facts + * (perspective / project / property names) through {@link CrossModelSupport} so a + * {@code relation.field} recipient or placeholder can point at an entity owned by another model. + * Returns {@code null} for a relation whose {@code uses} alias is not declared; a declared-but- + * unresolvable owner fails loudly through {@code CrossModelSupport.resolve} (the same "generate the + * leaf first" contract as generate targets and relation links). A {@code null} context (unit test) + * yields a no-op lookup so same-model resolution is unaffected. + */ + private static NotificationSupport.CrossModelLookup crossModelLookup(IntentModel model, IntentGenerationContext context) { + if (context == null) { + return relation -> null; + } + return relation -> { + UsesIntent uses = findUses(model, relation.getModel()); + if (uses == null) { + return null; + } + CrossModelSupport.TargetInfo target = CrossModelSupport.resolve(context, uses, relation.getTo()); + return new NotificationSupport.CrossModelTarget(target.perspectiveName(), uses.resolveProject(), uses.getModel(), + target.propertyNames()); + }; + } + + /** + * Surface a piece of glue that could not be emitted (e.g. an unresolvable notify recipient) both in + * the log AND as a generate-response issue, so the drop is not silent at the API level (dirigible + * #6360). The generation itself still succeeds - the issue is a warning, not a 422. + */ + private static void reportDroppedGlue(IntentGenerationContext context, String message) { + LOGGER.warn(message); + if (context != null) { + context.addIssue(message); + } + } + private static List> relationLoads(NotificationSupport.Plan plan) { List> loads = new ArrayList<>(); for (NotificationSupport.RelationLoad load : plan.loads()) { @@ -1540,6 +1576,11 @@ private static List> relationLoads(NotificationSupport.Plan entry.put("targetEntity", load.targetEntity()); entry.put("targetPerspective", load.targetPerspective()); entry.put("fkProperty", load.fkProperty()); + // Cross-model recipient/placeholder: the owner's model alias + project drive the OWNER-package + // import in the generated listener/job (generateUtils picks the gen folder from these). + entry.put("crossModel", load.crossModel()); + entry.put("targetModel", load.targetModel()); + entry.put("targetProject", load.targetProject()); loads.add(entry); } return loads; diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java index 35c6fd0ca7f..dd7c89792ca 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java @@ -63,6 +63,13 @@ public final class IntentGenerationContext { /** Bare file names written under {@link #projectRoot} during this generation pass. */ private final Set writtenFileNames = new LinkedHashSet<>(); + /** + * 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 + * silent at the API level (dirigible #6360) - the generation still succeeds. + */ + private final java.util.List issues = new java.util.ArrayList<>(); + IntentGenerationContext(IntentModel model, String projectRoot, String projectName, String workspaceName, String fallbackName, IRepository repository) { this.model = model; @@ -124,6 +131,24 @@ public Set getWrittenFileNames() { return Collections.unmodifiableSet(writtenFileNames); } + /** + * Record a non-fatal generation issue (dropped glue). Surfaced in the generate response. + * + * @param issue a human-readable description of what was not generated and why + */ + public void addIssue(String issue) { + issues.add(issue); + } + + /** + * The non-fatal issues collected during this pass. + * + * @return an unmodifiable view of the issues + */ + public java.util.List getIssues() { + return Collections.unmodifiableList(issues); + } + public String getProjectName() { return projectName; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java index 96fcefea98c..606364aea6a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java @@ -75,8 +75,11 @@ public IntentGenerationService(List generators, IReposito * @param scrubbed bare names of previously generated files removed because the intent no longer * declares their slice * @param codeGenerations ordered plan entries, each {@code {path, templateId, parameters}} + * @param issues non-fatal generation issues (glue that could not be emitted) - the pass still + * succeeded, but a caller should surface these so the drop is not silent (dirigible #6360) */ - public record GenerationResult(List written, List scrubbed, List> codeGenerations) { + public record GenerationResult(List written, List scrubbed, List> codeGenerations, + List issues) { } /** @@ -114,7 +117,7 @@ public GenerationResult generate(String yaml, String projectRoot, String project } List scrubbed = scrubStaleModelFiles(projectRoot, context.getWrittenFileNames()); List> plan = buildCodeGenerationPlan(context.getSettings(), context.getWrittenFileNames()); - return new GenerationResult(new ArrayList<>(context.getWrittenFileNames()), scrubbed, plan); + return new GenerationResult(new ArrayList<>(context.getWrittenFileNames()), scrubbed, plan, context.getIssues()); } /** diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java index f4f81f220c1..201a7a5ad22 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java @@ -41,8 +41,36 @@ public final class NotificationSupport { private NotificationSupport() {} - /** A one-hop to-one relation the listener must load before building the message. */ - public record RelationLoad(String local, String targetEntity, String targetPerspective, String fkProperty) { + /** + * A one-hop to-one relation the listener must load before building the message. A cross-model + * relation ({@code crossModel} true) points at an entity owned by another model, so the generated + * listener imports the OWNER's {@code targetModel}/{@code targetProject} generated + * Entity/Repository (the same registry-wide-compile mechanism the relation links / personal + * assignee use), not this project's. + */ + public record RelationLoad(String local, String targetEntity, String targetPerspective, String fkProperty, boolean crossModel, + String targetModel, String targetProject) { + } + + /** + * Resolves a cross-model to-one relation's target facts so a {@code relation.field} recipient or + * placeholder can reference an entity owned by another model. The lookup performs the IO (reads the + * owner's {@code .model} via {@code CrossModelSupport}); {@code NotificationSupport} stays free of + * Spring/IO so its path logic remains directly unit-testable. Returns {@code null} when the + * relation is not a resolvable cross-model target. + */ + @FunctionalInterface + public interface CrossModelLookup { + CrossModelTarget resolve(RelationIntent relation); + } + + /** + * The facts about a cross-model relation target a notification needs: the owner perspective and + * project/alias (to import the owner's generated Entity/Repository) plus its property names + * (PascalCase) to validate the referenced field. A {@code null} {@code propertyNames} means "not + * validated" (a naming-convention fallback) - the caller then trusts the authored field. + */ + public record CrossModelTarget(String perspectiveName, String project, String modelAlias, java.util.Set propertyNames) { } /** The translated, ready-to-render shape of a notification. */ @@ -89,7 +117,24 @@ public static String topicSuffix(String eventKind) { */ public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map byName, Map compositionParents) { - Resolver resolver = new Resolver(eventEntity, byName, compositionParents); + return plan(notification, eventEntity, byName, compositionParents, null); + } + + /** + * Build the full translation plan, resolving cross-model {@code relation.field} paths through the + * given {@code crossModel} lookup (pass {@code null} to keep the local-only behavior, e.g. in unit + * tests). + * + * @param notification the notification + * @param eventEntity the entity whose event fires it + * @param byName all LOCAL entities by name (to resolve same-model relation targets) + * @param compositionParents composition-parent map (to resolve a target's perspective) + * @param crossModel resolver for a cross-model relation's owner facts, or {@code null} + * @return the plan, or {@code null} if the {@code to} recipient cannot be resolved + */ + public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map byName, + Map compositionParents, CrossModelLookup crossModel) { + Resolver resolver = new Resolver(eventEntity, byName, compositionParents, crossModel); String to = resolver.value(notification.getTo()); if (to == null) { return null; // an unresolvable recipient relation.field - skip rather than email garbage @@ -148,12 +193,15 @@ private static final class Resolver { private final EntityIntent entity; private final Map byName; private final Map compositionParents; + private final CrossModelLookup crossModel; private final Map loads = new LinkedHashMap<>(); - Resolver(EntityIntent entity, Map byName, Map compositionParents) { + Resolver(EntityIntent entity, Map byName, Map compositionParents, + CrossModelLookup crossModel) { this.entity = entity; this.byName = byName; this.compositionParents = compositionParents; + this.crossModel = crossModel; } List loads() { @@ -220,14 +268,34 @@ private String access(String path) { if (relation == null) { return null; } + String pascalField = IntentNaming.pascalCase(fieldName); + // Cross-model relation.field (e.g. Customer.email where Customer is owned by another model): + // resolve the owner's facts through the injected lookup and load from the OWNER's package. + // A same-model relation resolves against the local byName map as before. + if (relation.getModel() != null && !relation.getModel() + .isBlank()) { + CrossModelTarget xm = crossModel == null ? null : crossModel.resolve(relation); + if (xm == null) { + return null; + } + // Validate the field against the owner model when its properties are known; a naming- + // convention fallback carries null propertyNames - then trust the authored field. + if (xm.propertyNames() != null && !xm.propertyNames() + .contains(pascalField)) { + return null; + } + loads.computeIfAbsent(relationName, name -> new RelationLoad(name, relation.getTo(), xm.perspectiveName(), + IntentNaming.pascalCase(name), true, xm.modelAlias(), xm.project())); + return "(" + relationName + " == null ? null : " + relationName + "." + pascalField + ")"; + } EntityIntent target = byName.get(relation.getTo()); if (target == null || fieldOf(target, fieldName) == null) { return null; } loads.computeIfAbsent(relationName, name -> new RelationLoad(name, relation.getTo(), - IntentEntities.resolvePerspective(relation.getTo(), compositionParents), IntentNaming.pascalCase(name))); + IntentEntities.resolvePerspective(relation.getTo(), compositionParents), IntentNaming.pascalCase(name), false, "", "")); // The listener loads the related entity into a local named after the relation. - return "(" + relationName + " == null ? null : " + relationName + "." + IntentNaming.pascalCase(fieldName) + ")"; + return "(" + relationName + " == null ? null : " + relationName + "." + pascalField + ")"; } private RelationIntent toOneRelation(String name) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 09b6d8032ae..eb2da02bfee 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -55,8 +55,13 @@ Treat it as the contract: anything you propose must parse and validate against i - **Lifecycle events** (`notifications`, `integrations`): exactly **one** of `onCreate` / `onUpdate` / `onDelete` per item, and it must reference a declared entity. - **Recipients** (`to` on notifications and schedule notify): a literal email address, a direct field - of the entity, or a **one-hop** `relation.field` (e.g. `member.email`). Multi-hop paths are not - supported. + of the entity, or a **one-hop** `relation.field` (e.g. `member.email`). The relation may be + **cross-model** - `partner.email` where `partner` targets an entity owned by another `uses` model + resolves against the owner's model (the generated listener imports the owner's Entity/Repository), + exactly like a cross-model dropdown. Multi-hop paths are not supported. +- **A recipient that cannot be resolved is surfaced, not silent.** If `to` names a field/relation that + does not exist, that notification or schedule is dropped and reported in the generate response's + `warnings` (as well as the server log) - fix the reference so the glue is emitted. - **Names are identifiers** within their block and must be unique. ## Capabilities diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java index 4039aa619a9..82d25f6895e 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java @@ -110,6 +110,57 @@ void unresolvableRecipientRelationYieldsNoPlan() { assertNull(NotificationSupport.plan(notification("nope.email", "s"), byName.get("Order"), byName, Map.of())); } + /** Order --partner (cross-model)--> Partner owned by another model, referenced as partner.email. */ + private static Map crossModelEventModel() { + RelationIntent partner = toOne("partner", "Partner"); + partner.setModel("acme-partners"); // cross-model: Partner is NOT a local entity + EntityIntent order = new EntityIntent(); + order.setName("Order"); + order.setFields(List.of(field("id"), field("total"))); + order.setRelations(List.of(partner)); + Map byName = new LinkedHashMap<>(); + byName.put("Order", order); // Partner is intentionally absent from the local byName map + return byName; + } + + @Test + void crossModelRecipientResolvesThroughTheLookupAndLoadsFromTheOwner() { + Map byName = crossModelEventModel(); + NotificationSupport.CrossModelLookup lookup = relation -> new NotificationSupport.CrossModelTarget("Partner", "acme-partners", + "acme-partners", java.util.Set.of("Id", "Name", "Email")); + NotificationSupport.Plan plan = NotificationSupport.plan(notification("partner.email", "Order for {partner.name}"), + byName.get("Order"), byName, Map.of(), lookup); + + assertEquals(1, plan.loads() + .size()); + NotificationSupport.RelationLoad load = plan.loads() + .get(0); + assertTrue(load.crossModel(), "a cross-model relation load must be flagged so the owner package is imported"); + assertEquals("Partner", load.targetEntity()); + assertEquals("Partner", load.targetPerspective()); + assertEquals("acme-partners", load.targetModel()); + assertEquals("acme-partners", load.targetProject()); + assertEquals("Partner", load.fkProperty()); + assertEquals("(partner == null ? null : partner.Email)", plan.toExpression()); + } + + @Test + void crossModelRecipientFieldValidatedAgainstOwnerProperties() { + Map byName = crossModelEventModel(); + // The owner model has no 'ceo' property -> the field is rejected -> no plan (recipient + // unresolvable). + NotificationSupport.CrossModelLookup lookup = relation -> new NotificationSupport.CrossModelTarget("Partner", "acme-partners", + "acme-partners", java.util.Set.of("Id", "Name", "Email")); + assertNull(NotificationSupport.plan(notification("partner.ceo", "s"), byName.get("Order"), byName, Map.of(), lookup)); + } + + @Test + void crossModelRecipientWithoutLookupYieldsNoPlan() { + Map byName = crossModelEventModel(); + // No lookup (e.g. a unit path with no repository) -> a cross-model recipient cannot resolve. + assertNull(NotificationSupport.plan(notification("partner.email", "s"), byName.get("Order"), byName, Map.of())); + } + @Test void guardTranslatesSingleComparisonElseFiresAlways() { assertEquals("true", NotificationSupport.guard(null)); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template index 8ef12b105f0..8e22feb976b 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template @@ -17,8 +17,8 @@ import org.eclipse.dirigible.sdk.job.JobHandler; import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; #foreach($load in $relationLoads) -import gen.${javaGenFolderName}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity; -import gen.${javaGenFolderName}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository; +import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity; +import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository; #end /** diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template index 05974a2890b..7f96a5c074a 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template @@ -13,8 +13,8 @@ import org.eclipse.dirigible.sdk.utils.Json; import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; #foreach($load in $relationLoads) -import gen.${javaGenFolderName}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity; -import gen.${javaGenFolderName}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository; +import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity; +import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository; #end /** diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 6d9996f9054..9f433f7bcea 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -721,9 +721,12 @@ export function generateFiles(model, parameters, templateSources) { javaPerspective: sanitizeJavaIdentifier(model.notifications[n].perspective), topicSuffix: model.notifications[n].topicSuffix, // Each one-hop relation load needs the lowercased Java package of its target. + // A cross-model load imports from the OWNER model's gen folder (its sanitized + // alias); a same-model load uses this project's (dirigible #6360). relationLoads: (model.notifications[n].relationLoads || []).map(load => ({ ...load, - javaTargetPerspective: sanitizeJavaIdentifier(load.targetPerspective) + javaTargetPerspective: sanitizeJavaIdentifier(load.targetPerspective), + javaGenFolder: load.crossModel ? sanitizeJavaIdentifier(load.targetModel) : parameters.javaGenFolderName })), guardExpression: model.notifications[n].guardExpression, toExpression: model.notifications[n].toExpression, @@ -761,7 +764,8 @@ export function generateFiles(model, parameters, templateSources) { action: sc.action || "notify", relationLoads: (sc.relationLoads || []).map(load => ({ ...load, - javaTargetPerspective: sanitizeJavaIdentifier(load.targetPerspective) + javaTargetPerspective: sanitizeJavaIdentifier(load.targetPerspective), + javaGenFolder: load.crossModel ? sanitizeJavaIdentifier(load.targetModel) : parameters.javaGenFolderName })), toExpression: sc.toExpression, subjectExpression: sc.subjectExpression,