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 09f3af6b7a..ac6fe9b9d2 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 @@ -34,9 +34,11 @@ import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.eclipse.dirigible.components.intent.model.ExpansionIntent; import org.eclipse.dirigible.components.intent.model.RollupIntent; +import org.eclipse.dirigible.components.intent.model.ScheduleConditionIntent; import org.eclipse.dirigible.components.intent.model.ScheduleIntent; import org.eclipse.dirigible.components.intent.model.SettlementIntent; import org.eclipse.dirigible.components.intent.model.UsesIntent; +import org.eclipse.dirigible.components.intent.parser.IntentValidationException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; @@ -203,10 +205,12 @@ private static List> buildTriggers(IntentModel model, Map}; field - * assignments are pre-rendered against it, defaults against literals. + * {@code forEach} collection entity is LOCAL by default, or cross-model when the child's + * {@code forEach} carries a {@code model:} alias (resolved through {@link CrossModelSupport}). The + * row variable is {@code r}; field assignments are pre-rendered against it, defaults against + * literals. */ - private static List> buildGenerateChildren(List children, UsesIntent uses, + private static List> buildGenerateChildren(List children, UsesIntent uses, IntentModel model, Map byName, Map compositionParents, IntentGenerationContext context, int depth) { List> result = new ArrayList<>(); for (GenerateChildIntent child : children) { @@ -232,8 +236,24 @@ private static List> buildGenerateChildren(List match = (Map) child.getForEach() .get("match"); @@ -248,7 +268,8 @@ private static List> buildGenerateChildren(List> buildSchedules(IntentModel model, Map). It is resolved against + // the owner's .model through CrossModelSupport (workspace first, registry fallback) - the + // same two-tier, order-independent resolution relations / dependsOn / leafOnly use. A null + // context (unit test) yields the naming-convention defaults so the shape can be asserted + // without a repository. + boolean sourceCrossModel = schedule.getModel() != null && !schedule.getModel() + .isBlank(); + CrossModelSupport.TargetInfo sourceTarget = null; + if (sourceCrossModel) { + UsesIntent sourceUses = findUses(model, schedule.getModel()); + if (sourceUses == null) { + continue; // parser already reported the undeclared alias + } + try { + sourceTarget = CrossModelSupport.resolve(context, sourceUses, entity); + } catch (IntentValidationException ex) { + reportDroppedGlue(context, "Schedule [" + schedule.getName() + "] source entity [" + entity + "] in model [" + + schedule.getModel() + "] cannot be resolved: " + ex.getMessage() + " - the schedule was NOT generated"); + continue; + } + } else if (entity == null || !byName.containsKey(entity)) { continue; } boolean generates = schedule.getGenerate() != null; @@ -1916,6 +1957,19 @@ private static List> buildSchedules(IntentModel model, Map entry = new LinkedHashMap<>(); entry.put("name", schedule.getName()); @@ -1923,11 +1977,17 @@ private static List> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map> buildSchedulesForTest(IntentModel model) { null); } + /** + * The first cross-model schedule reference that does not resolve against its owner model, or + * {@code null} when every reference resolves. Validates the references that read the source row + * (each {@code where} field, each {@code generate.map} source, and recursively each child + * {@code forEach.match} source) against the source's properties, and - for a cross-model + * {@code forEach} collection - its own match-key + child-map references against the collection + * owner's properties. Skips a source/collection whose owner model was not resolved + * ({@code propertyNames() == null}, the convention fallback), the same tolerance {@code dependsOn} + * uses. + */ + private static String firstUnresolvableScheduleRef(IntentModel model, ScheduleIntent schedule, + CrossModelSupport.TargetInfo sourceTarget, IntentGenerationContext context) { + java.util.Set sourceProps = sourceTarget == null ? null : sourceTarget.propertyNames(); + if (sourceProps != null) { + for (ScheduleConditionIntent condition : schedule.getWhere()) { + if (isMissing(sourceProps, condition.getField())) { + return "where field [" + condition.getField() + "]"; + } + } + for (Map.Entry mapping : schedule.getGenerate() + .getMap() + .entrySet()) { + if (isMissing(sourceProps, mapping.getValue())) { + return "generate map source [" + mapping.getValue() + "]"; + } + } + } + return firstUnresolvableChildRef(model, schedule.getGenerate() + .getChildren(), + sourceProps, context); + } + + /** + * Recursive helper for {@link #firstUnresolvableScheduleRef}: validates every child's + * {@code forEach.match} source against the (cross-model) source row, and - for a cross-model + * {@code forEach} collection - its match-key + this child's {@code map} sources against the + * collection owner's properties. + */ + private static String firstUnresolvableChildRef(IntentModel model, List children, + java.util.Set sourceProps, IntentGenerationContext context) { + if (children == null) { + return null; + } + for (GenerateChildIntent child : children) { + Object matchObject = child.getForEach() + .get("match"); + if (matchObject instanceof Map) { + Map match = (Map) matchObject; + if (sourceProps != null) { + for (Map.Entry condition : match.entrySet()) { + if (isMissing(sourceProps, String.valueOf(condition.getValue()))) { + return "generate child forEach match source [" + condition.getValue() + "]"; + } + } + } + Object forEachModel = child.getForEach() + .get("model"); + if (forEachModel != null && !String.valueOf(forEachModel) + .isBlank()) { + String collection = String.valueOf(child.getForEach() + .get("entity")); + UsesIntent collectionUses = findUses(model, String.valueOf(forEachModel)); + if (collectionUses != null) { + CrossModelSupport.TargetInfo collectionTarget; + try { + collectionTarget = CrossModelSupport.resolve(context, collectionUses, collection); + } catch (IntentValidationException ex) { + return "forEach collection [" + collection + "] in model [" + forEachModel + "] (" + ex.getMessage() + ")"; + } + java.util.Set collectionProps = collectionTarget.propertyNames(); + if (collectionProps != null) { + for (Map.Entry condition : match.entrySet()) { + if (isMissing(collectionProps, String.valueOf(condition.getKey()))) { + return "generate child forEach match field [" + condition.getKey() + "]"; + } + } + for (String mapSource : child.getMap() + .values()) { + if (isMissing(collectionProps, mapSource)) { + return "generate child map source [" + mapSource + "]"; + } + } + } + } + } + } + String nested = firstUnresolvableChildRef(model, child.getChildren(), sourceProps, context); + if (nested != null) { + return nested; + } + } + return null; + } + + /** + * Whether an authored field reference is absent from a set of (PascalCase) owner property names, + * applying the same PascalCase mapping the criteria / assignment renderers use. + */ + private static boolean isMissing(java.util.Set properties, String authoredName) { + return authoredName != null && !authoredName.isBlank() && !properties.contains(IntentNaming.pascalCase(authoredName)); + } + /** * A cross-model relation resolver for the notify machinery: reads the owner model's facts * (perspective / project / property names) through {@link CrossModelSupport} so a diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java index c60719a575..f60028d56b 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java @@ -37,6 +37,15 @@ public class ScheduleIntent { private String name; private String cron; private String entity; + + /** + * Optional model alias (from the model's {@code uses:} list) the {@link #entity} source lives in. + * Blank means the source is a local entity of this model (the default, fully backward compatible). + * A cross-model source is read-only and supports the {@code generate} action only; its + * {@code where}/{@code map}/{@code match} field references are validated at generation time against + * the owner's {@code .model}. + */ + private String model; private List where = new ArrayList<>(); private NotificationIntent notify; private GeneratesIntent generate; @@ -65,6 +74,14 @@ public void setEntity(String entity) { this.entity = entity; } + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + public List getWhere() { return where; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 7bb8c8f2f0..1d6de0e76e 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -667,8 +667,23 @@ private static void validateSchedules(IntentModel model, Set entityNames .isBlank()) { issues.add("schedule [" + name + "] has no cron expression"); } + // A cross-model source (model: ) lives in another model; its existence and its + // where/map/match field references are validated at GENERATION time against the owner's + // .model (the same design-time split relations / dependsOn / leafOnly already use), so the + // local entity/field checks are skipped and source stays null. + boolean crossModelSource = schedule.getModel() != null && !schedule.getModel() + .isBlank(); EntityIntent source = null; - if (schedule.getEntity() == null || !entityNames.contains(schedule.getEntity())) { + if (crossModelSource) { + if (!usesAliases.contains(schedule.getModel())) { + issues.add("schedule [" + name + "] source model [" + schedule.getModel() + + "] is not a declared uses: alias (declare it under the model's uses:)"); + } + if (schedule.getEntity() == null || schedule.getEntity() + .isBlank()) { + issues.add("schedule [" + name + "] queries unknown entity [" + schedule.getEntity() + "]"); + } + } else if (schedule.getEntity() == null || !entityNames.contains(schedule.getEntity())) { issues.add("schedule [" + name + "] queries unknown entity [" + schedule.getEntity() + "]"); } else { source = byName.get(schedule.getEntity()); @@ -691,7 +706,16 @@ private static void validateSchedules(IntentModel model, Set entityNames } else if (!hasNotify && !hasGenerate) { issues.add("schedule [" + name + "] has no action (add a notify or a generate)"); } else if (hasNotify) { - validateNotifyBlock(schedule.getNotify(), "schedule [" + name + "] notify", schedule.getEntity(), model, issues); + // v1 scope: the notify machinery resolves recipients/placeholders/relation loads against a + // LOCAL EntityIntent; a cross-model source has only TargetInfo metadata, so notify is not + // yet supported there. Keep the schedule in the source's model, or drop model:. + if (crossModelSource) { + issues.add("schedule [" + name + "] uses a cross-model source with notify - a cross-model schedule source" + + " supports the generate action; notify needs the source's relation metadata - keep the schedule in the" + + " source's model or drop model:"); + } else { + validateNotifyBlock(schedule.getNotify(), "schedule [" + name + "] notify", schedule.getEntity(), model, issues); + } } else { validateScheduleGenerate(schedule, source, entityNames, usesAliases, issues); } @@ -732,7 +756,7 @@ private static void validateScheduleGenerate(ScheduleIntent schedule, EntityInte + " use an on-demand generates action for document-to-document cloning"); } if (g.getChildren() != null) { - validateGenerateChildren(name, g.getChildren(), 1, source, entityNames, issues); + validateGenerateChildren(name, g.getChildren(), 1, source, entityNames, usesAliases, issues); } } @@ -745,7 +769,7 @@ private static void validateScheduleGenerate(ScheduleIntent schedule, EntityInte * (resolved in the target's model at generation). Depth is capped at two levels. */ private static void validateGenerateChildren(String name, List children, int depth, EntityIntent source, - Set entityNames, List issues) { + Set entityNames, Set usesAliases, List issues) { if (depth > 2) { issues.add("schedule [" + name + "] generate children nest deeper than two levels - flatten the shape"); return; @@ -783,7 +807,18 @@ private static void validateGenerateChildren(String name, List); + // its existence and match-key field are then validated at generation time. + Object forEachModel = child.getForEach() + .get("model"); + boolean forEachCrossModel = forEachModel != null && !String.valueOf(forEachModel) + .isBlank(); + if (forEachCrossModel) { + if (!usesAliases.contains(String.valueOf(forEachModel))) { + issues.add(subject + " forEach model [" + forEachModel + + "] is not a declared uses: alias (declare it under the model's uses:)"); + } + } else if (!entityNames.contains(collection)) { issues.add(subject + " forEach entity [" + collection + "] is not a local entity of this model"); } Object match = child.getForEach() @@ -793,7 +828,7 @@ private static void validateGenerateChildren(String name, List` +to read the source from another model - so the schedule can live with the consumer (the module it +generates into) instead of being forced into the source's module with a back-reference. The source is +**read-only** (a schedule never writes it). A `forEach` collection may likewise be cross-model with its +own `model:` alias. Both aliases must be declared under the model's `uses:`. + +```yaml +# lives in the module that owns the created rows (e.g. timesheets), which already uses: projects +uses: + - { model: projects } + +schedules: + - name: monthlyProjectTimesheets + cron: "0 0 2 1 * ?" + entity: Project + model: projects # the source Project lives in the projects model + where: + - { field: Status, op: eq, value: 2 } + generate: + to: ProjectTimesheet # now LOCAL (no uses: needed) + map: { Project: id, Customer: Customer } + defaults: { Period: now } + children: + - to: EmployeeTimesheet + parent: ProjectTimesheet + forEach: + entity: EmployeeProjectAssignment + model: projects # the forEach collection is also cross-model + match: { Project: id } + map: { Employee: Employee } +``` + +- **v1 scope: `generate` only.** A cross-model source with a `notify` action is rejected at parse + (notify needs the source's relation metadata, which only a local entity carries) - keep such a + schedule in the source's model, or drop `model:`. +- **Validation split** (the same one relations use): that `model:` names a declared `uses:` alias is + checked at parse; the source entity's existence and the `where` / `map` / `match` field references + are checked at **generation** against the owner's `.model` (generate the owner model first, or + install/publish its prebuilt module). A missing owner or a mistyped field drops that schedule with a + warning in the generate response - it never emits a job that cannot compile. + +**Rules:** unique name, a `cron`, a declared `entity` (local, or a cross-model source via `model:`), +`where` operators from the allowed list, and **exactly one** of `notify` (valid recipient; local source +only) / `generate` (a declared/cross-model `to`, a `map` over the row's fields/to-one relations, +optional `children`). Composition-item cloning via `items:` is **not** available on a schedule (it needs +a selected document) - use an on-demand `generates` action for document-to-document cloning, or +`generate.children` for the fan-out shape above. ### integrations - outbound HTTP on a data change diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java index 6fecf70a72..1085efbfd6 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSchedulesTest.java @@ -157,6 +157,100 @@ void generateScheduleResolvesCrossModelTarget() { assertEquals("SalesInvoice", s.get("genToPerspective")); } + @Test + void localSourceScheduleMarksSourceAsNotCrossModel() { + // Backward-compatibility: a local-source schedule carries the new source keys with the + // not-cross-model values, so the template's ${sourceGenFolder} stays this project's folder. + String yaml = """ + name: hr + entities: + - name: Employee + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: status, type: string } + - name: EmployeeTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Employee, kind: manyToOne, to: Employee } + schedules: + - name: monthly-timesheets + cron: "0 0 1 1 * ?" + entity: Employee + generate: + to: EmployeeTimesheet + map: + Employee: id + """; + IntentModel model = IntentParser.parse(yaml); + Map s = GlueIntentGenerator.buildSchedulesForTest(model) + .get(0); + assertEquals(false, s.get("sourceCrossModel")); + assertEquals("", s.get("sourceModel")); + assertEquals("Employee", s.get("perspective")); + } + + @SuppressWarnings("unchecked") + @Test + void crossModelSourceScheduleEmitsSourceKeysAndCrossModelForEach() { + // The source Project lives in the projects model (a declared uses: alias); with no repository + // (null context) CrossModelSupport falls back to naming-convention defaults, enough to assert + // the emitted cross-model source + forEach keys and the criteria against the source row. + String yaml = """ + name: timesheets + uses: + - { model: projects } + entities: + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: period, type: date } + - name: EmployeeTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: ProjectTimesheet, kind: manyToOne, to: ProjectTimesheet } + schedules: + - name: monthly-project-timesheets + cron: "0 0 2 1 * ?" + entity: Project + model: projects + where: + - { field: status, op: eq, value: 2 } + generate: + to: ProjectTimesheet + map: + Period: now + children: + - to: EmployeeTimesheet + parent: ProjectTimesheet + forEach: + entity: EmployeeProjectAssignment + model: projects + match: { Project: id } + map: { Employee: Employee } + """; + IntentModel model = IntentParser.parse(yaml); + Map s = GlueIntentGenerator.buildSchedulesForTest(model) + .get(0); + assertEquals("generate", s.get("action")); + assertEquals(true, s.get("sourceCrossModel")); + assertEquals("projects", s.get("sourceModel")); + // Convention fallback (no repository): the owner perspective + key default to the entity name / Id. + assertEquals("Project", s.get("perspective")); + assertEquals("Id", s.get("attachKeyProperty")); + assertTrue(((String) s.get("criteriaExpression")).contains(".eq(\"Status\", 2)"), "criteria: " + s.get("criteriaExpression")); + + List> children = (List>) s.get("genChildren"); + assertEquals(1, children.size()); + Map child = children.get(0); + assertEquals(true, child.get("forEachCrossModel")); + assertEquals("projects", child.get("forEachModel")); + assertEquals("EmployeeProjectAssignment", child.get("forEachEntity")); + // Convention fallback: the cross-model collection's perspective defaults to the entity name. + assertEquals("EmployeeProjectAssignment", child.get("forEachPerspective")); + } + @Test void notifyScheduleStillEmitsMailPlan() { String yaml = """ diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index 689a2d25dd..41f08d12d9 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -1376,6 +1376,127 @@ void scheduleGenerateWithBadMapSourceIsRejected() { "expected a bad-map-source issue, got: " + ex.getIssues()); } + /** + * A consumer model that owns the created rows and reaches a source entity ({@code Project}) in a + * declared {@code uses:} model. The generate block is appended per test. + */ + private static final String CROSS_SCHEDULE_HEAD = """ + name: timesheets + uses: + - { model: projects } + entities: + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: period, type: date } + - name: EmployeeTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: ProjectTimesheet, kind: manyToOne, to: ProjectTimesheet } + schedules: + - name: monthly-project-timesheets + cron: "0 0 2 1 * ?" + entity: Project + model: projects + where: + - { field: Status, op: eq, value: 2 } + """; + + @Test + void crossModelScheduleSourceParsesWhenModelIsDeclared() { + // The source Project is not a local entity, but its model is a declared uses: alias - so the + // local entity check is skipped and its where/map fields validate at generation time, not here. + String yaml = CROSS_SCHEDULE_HEAD + """ + generate: + to: ProjectTimesheet + map: + Period: now + children: + - to: EmployeeTimesheet + parent: ProjectTimesheet + forEach: + entity: EmployeeProjectAssignment + model: projects + match: { Project: id } + map: { Employee: Employee } + """; + IntentParser.parse(yaml); + } + + @Test + void crossModelScheduleSourceToUndeclaredModelIsRejected() { + String yaml = """ + name: timesheets + entities: + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: period, type: date } + schedules: + - name: monthly-project-timesheets + cron: "0 0 2 1 * ?" + entity: Project + model: projects + generate: + to: ProjectTimesheet + map: { Period: now } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("source model [projects] is not a declared uses: alias")), + "expected an undeclared-source-model issue, got: " + ex.getIssues()); + } + + @Test + void crossModelScheduleSourceWithNotifyIsRejected() { + String yaml = """ + name: timesheets + uses: + - { model: projects } + entities: + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + schedules: + - name: nudge + cron: "0 0 2 1 * ?" + entity: Project + model: projects + notify: + to: contactEmail + subject: "x" + body: "y" + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("notify needs the source's relation metadata")), + "expected a cross-model-notify-unsupported issue, got: " + ex.getIssues()); + } + + @Test + void crossModelForEachToUndeclaredModelIsRejected() { + String yaml = CROSS_SCHEDULE_HEAD + """ + generate: + to: ProjectTimesheet + map: { Period: now } + children: + - to: EmployeeTimesheet + parent: ProjectTimesheet + forEach: + entity: EmployeeProjectAssignment + model: staffing + match: { Project: id } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("forEach model [staffing] is not a declared uses: alias")), + "expected an undeclared-forEach-model issue, got: " + ex.getIssues()); + } + /** A complete personalized model built line by line - no text-block margin surprises. */ private static String personalYaml(String employeeExtra, String requestFields, String requestRelations) { return "name: hr\n" // 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 d6f4ca87d7..d312d87348 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 @@ -14,8 +14,8 @@ import org.eclipse.dirigible.sdk.mail.Mail; #end import org.eclipse.dirigible.sdk.job.JobHandler; -import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; -import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; +import gen.${sourceGenFolder}.data.${javaPerspective}.${entity}Entity; +import gen.${sourceGenFolder}.data.${javaPerspective}.${entity}Repository; #foreach($load in $relationLoads) import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Entity; import gen.${load.javaGenFolder}.data.${load.javaTargetPerspective}.${load.targetEntity}Repository; diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js index 39d50f0964..9953ebbd05 100644 --- a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js @@ -575,7 +575,7 @@ editorView.controller('IntentEditorController', ($scope, $http, ViewParameters, { list: $scope.model.forms, icon: ICON.form, color: COLOR.output, entity: f => f.forEntity, detail: () => 'form' }, { list: $scope.model.reports, icon: ICON.report, color: COLOR.output, entity: r => r.source, detail: r => r.widget ? 'report • KPI ' + (r.widget.kind || (r.widget.value ? 'value' : 'count')) : 'report' }, { list: $scope.model.notifications, icon: ICON.notification, color: COLOR.glue, entity: n => (eventOf(n.event) || {}).entity, detail: n => eventVerb((eventOf(n.event) || {}).kind) + ' → email' }, - { list: $scope.model.schedules, icon: ICON.schedule, color: COLOR.glue, entity: s => s.entity, detail: s => s.cron || 'scheduled' }, + { list: $scope.model.schedules, icon: ICON.schedule, color: COLOR.glue, entity: s => s.model ? null : s.entity, detail: s => (s.model ? s.model + '.' + s.entity + ' • ' : '') + (s.cron || 'scheduled') }, { list: $scope.model.integrations, icon: ICON.integration, color: COLOR.glue, entity: i => (eventOf(i.event) || {}).entity, detail: i => (i.method || 'POST') + ' ' + eventVerb((eventOf(i.event) || {}).kind) }, { list: $scope.model.inbound, icon: ICON.inbound, color: COLOR.glue, entity: w => w.create, detail: w => 'POST ' + (w.path || '') }, { list: $scope.model.rollups, icon: ICON.rollup, color: COLOR.glue, entity: r => r.entity, detail: r => '→ ' + (rollupParent(r) || '?') + '.' + (r.field || '') } 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 65b18667a8..d0648a9944 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 @@ -823,6 +823,11 @@ export function generateFiles(model, parameters, templateSources) { entity: sc.entity, perspective: sc.perspective, javaPerspective: sanitizeJavaIdentifier(sc.perspective), + // The source entity's gen folder: the sanitized OWNER model when the + // source is cross-model (the job imports the owner's gen classes - the + // leafOnly precedent), else this project's own folder. Always supplied, + // so a local-source schedule stays byte-identical. + sourceGenFolder: sc.sourceCrossModel ? sanitizeJavaIdentifier(sc.sourceModel) : parameters.javaGenFolderName, criteriaExpression: sc.criteriaExpression, action: sc.action || "notify", relationLoads: (sc.relationLoads || []).map(load => ({ @@ -860,19 +865,22 @@ export function generateFiles(model, parameters, templateSources) { // Collection-driven children: fully-qualified classes resolved here // (the template engine knows the path layout; the glue carried only // logical names). The child target lives in the generation target's - // model; the forEach collection is always LOCAL. + // model; the forEach collection is LOCAL by default, or in the owner + // model when the child's forEach carried a model: alias. genChildren: (sc.genChildren || []).map(function resolveChild(c) { const childGenFolder = c.toCrossModel ? sanitizeJavaIdentifier(c.toModel) : parameters.javaGenFolderName; const childPkg = 'gen.' + childGenFolder + '.data.' + sanitizeJavaIdentifier(c.toPerspective) + '.'; + const forEachGenFolder = c.forEachCrossModel ? sanitizeJavaIdentifier(c.forEachModel) : parameters.javaGenFolderName; + const forEachPkg = 'gen.' + forEachGenFolder + '.data.' + sanitizeJavaIdentifier(c.forEachPerspective) + '.'; return { ...c, toEntityClass: childPkg + c.toEntity + 'Entity', toRepositoryClass: childPkg + c.toEntity + 'Repository', forEachEntityClass: c.forEachEntity - ? 'gen.' + parameters.javaGenFolderName + '.data.' + sanitizeJavaIdentifier(c.forEachPerspective) + '.' + c.forEachEntity + 'Entity' + ? forEachPkg + c.forEachEntity + 'Entity' : undefined, forEachRepositoryClass: c.forEachEntity - ? 'gen.' + parameters.javaGenFolderName + '.data.' + sanitizeJavaIdentifier(c.forEachPerspective) + '.' + c.forEachEntity + 'Repository' + ? forEachPkg + c.forEachEntity + 'Repository' : undefined, children: (c.children || []).map(resolveChild) }; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelScheduleSourceIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelScheduleSourceIT.java new file mode 100644 index 0000000000..aff3199a4d --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentCrossModelScheduleSourceIT.java @@ -0,0 +1,324 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import com.google.gson.Gson; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.repository.api.IResource; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Cross-model schedule SOURCE coverage: a schedule owned by the CONSUMER model iterates a source + * entity that lives in another (owner) model via {@code model: }, generating a local + * target record - and a cross-model forEach child - per matching source row. + * + *

+ * Two mutually-referencing intent modules: {@code xmsource} owns the source entities + * ({@code Project} + its {@code ProjectAssignment} collection), {@code xmconsumer} + * {@code uses: xmsource} and owns the generated {@code ProjectTimesheet} (+ + * {@code EmployeeTimesheet} lines). The schedule's source is cross-model; so is the forEach + * collection. + * + *

+ * Per the emission + runtime testing contract the assertions target the OUTERMOST observable layer: + * first the generated {@code Job} TOKENS (the owner-package source import, the Criteria expression, + * the owner-package forEach class FQNs), then the RUNNING instance - the cron-fired job creates the + * target + child rows, asserted over REST. Two loud-failure paths are covered too: the owner + * {@code .model} absent, and a mistyped {@code where} field - both drop the schedule with a warning + * in the generate response (never a job that cannot compile). + */ +class IntentCrossModelScheduleSourceIT extends IntegrationTest { + + private static final String WORKSPACE = "workspace"; + private static final String OWNER = "xmsource"; + private static final String CONSUMER = "xmconsumer"; + + /** The owner module: the schedule's cross-model source entities. */ + private static final String OWNER_INTENT = """ + name: xmsource + description: cross-model schedule source fixture - owns the source entities + + entities: + - name: Project + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - { name: status, type: integer, required: true } + + - name: ProjectAssignment + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: project, type: integer, required: true } + - { name: employee, type: string, required: true, length: 100 } + """; + + /** + * The consumer module: owns the created rows and schedules off the owner's Project (+ its + * ProjectAssignment collection), both cross-model. A fast cron so the job fires within the test. + */ + private static String consumerIntent(String whereField) { + return """ + name: xmconsumer + description: cross-model schedule consumer fixture - owns created rows, schedules off xmsource + + uses: + - { model: xmsource } + + entities: + - name: ProjectTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: project, type: integer } + - { name: period, type: date } + + - name: EmployeeTimesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: employee, type: string, length: 100 } + relations: + - { name: ProjectTimesheet, kind: manyToOne, to: ProjectTimesheet } + + schedules: + - name: monthlyProjectTimesheets + cron: "0/5 * * * * ?" + entity: Project + model: xmsource + where: + - { field: %s, op: eq, value: 1 } + generate: + to: ProjectTimesheet + map: + Project: id + defaults: + Period: now + children: + - to: EmployeeTimesheet + parent: ProjectTimesheet + forEach: + entity: ProjectAssignment + model: xmsource + match: { Project: id } + map: { Employee: Employee } + """.formatted(whereField); + } + + @Autowired + private IRepository repository; + @Autowired + private RestAssuredExecutor restAssuredExecutor; + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Test + void cross_model_source_schedule_generates_target_and_child_rows() { + generateProject(OWNER, OWNER_INTENT); + generateProject(CONSUMER, consumerIntent("status")); + + assertJobTokens(); + + publishProject(OWNER); + publishProject(CONSUMER); + synchronizationProcessor.forceProcessSynchronizers(); + + // Seed the source rows over the OWNER's REST controllers: one active Project + two assignments. + int projectId = createProject(); + createAssignment(projectId, "emp-1"); + createAssignment(projectId, "emp-2"); + + assertGeneratedRowsAppear(); + } + + /** + * Layer 1: the generated Job imports the OWNER's gen classes and queries the cross-model source. + */ + private void assertJobTokens() { + String job = contentOf(CONSUMER, "gen/events/xmconsumer/MonthlyProjectTimesheetsJob.java"); + assertTrue(job.contains("package gen.events.xmconsumer;"), "the job lives in the consumer's events package"); + assertTrue(job.contains("import gen.xmsource.data.project.ProjectEntity;"), + "the source entity import must resolve against the OWNER's gen package"); + assertTrue(job.contains("import gen.xmsource.data.project.ProjectRepository;"), + "the source repository import must resolve against the OWNER's gen package"); + assertTrue(job.contains(".eq(\"Status\", 1)"), "the Criteria must filter the source rows: " + job); + assertTrue(job.contains("gen.xmsource.data.projectassignment.ProjectAssignmentRepository"), + "the cross-model forEach collection class must resolve against the OWNER's gen package"); + assertTrue(job.contains("gen.xmconsumer.data.projecttimesheet.ProjectTimesheetEntity"), + "the generation target stays local to the consumer"); + assertTrue(job.contains("gen.xmconsumer.data.employeetimesheet.EmployeeTimesheetEntity"), + "the child target stays local to the consumer"); + } + + /** + * Layer 2: the running instance - the cron-fired job reads the cross-model source and creates the + * local target + child rows, so both consumer controllers report a non-empty count. + */ + private void assertGeneratedRowsAppear() { + String api = "/services/java/" + CONSUMER + "/gen/xmconsumer/api"; + // The job fires every five seconds; allow a generous window for sync + the first tick. + restAssuredExecutor.execute(() -> given().when() + .get(api + "/projecttimesheet/ProjectTimesheetController/count") + .then() + .statusCode(200) + .body("count", greaterThanOrEqualTo(1)), + 90); + restAssuredExecutor.execute(() -> given().when() + .get(api + "/employeetimesheet/EmployeeTimesheetController/count") + .then() + .statusCode(200) + .body("count", greaterThanOrEqualTo(1)), + 90); + } + + @Test + void missing_owner_model_drops_the_schedule_with_a_warning() { + // The consumer is generated WITHOUT the owner model present in the workspace or registry, so the + // cross-model source cannot be resolved: the schedule is dropped with a warning, not a 422. + writeIntent(CONSUMER, consumerIntent("status")); + List warnings = generateWarnings(CONSUMER); + assertTrue(warnings.stream() + .anyMatch(w -> w.contains("monthlyProjectTimesheets") && w.contains("cannot be resolved") + && w.contains("xmsource")), + "expected an unresolvable-owner warning naming the schedule and owner model, got: " + warnings); + } + + @Test + void mistyped_where_field_drops_the_schedule_naming_the_owner_model() { + // With the owner model present, a where field that does not exist on the owner's Project is + // caught at generation and the schedule is dropped with a warning naming the owner model. + generateProject(OWNER, OWNER_INTENT); + writeIntent(CONSUMER, consumerIntent("nonexistentField")); + List warnings = generateWarnings(CONSUMER); + assertTrue(warnings.stream() + .anyMatch(w -> w.contains("monthlyProjectTimesheets") && w.contains("where field [nonexistentField]") + && w.contains("xmsource")), + "expected a where-field warning naming the schedule, field and owner model, got: " + warnings); + } + + private int createProject() { + AtomicInteger id = new AtomicInteger(); + restAssuredExecutor.execute(() -> id.set(given().contentType("application/json") + .body("{\"Name\":\"Alpha\",\"Status\":1}") + .when() + .post("/services/java/" + OWNER + "/gen/xmsource/api/project/ProjectController") + .then() + .statusCode(200) + .extract() + .path("Id")), + 60); + return id.get(); + } + + private void createAssignment(int projectId, String employee) { + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Project\":" + projectId + ",\"Employee\":\"" + employee + "\"}") + .when() + .post("/services/java/" + OWNER + + "/gen/xmsource/api/projectassignment/ProjectAssignmentController") + .then() + .statusCode(200)); + } + + /** Write the intent and drive model-to-code from the generate response's own plan. */ + private void generateProject(String project, String yaml) { + writeIntent(project, yaml); + AtomicReference>> plan = new AtomicReference<>(); + restAssuredExecutor.execute(() -> plan.set(given().when() + .post("/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + + project + "&path=app.intent") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("codeGenerations"))); + for (Map codeGeneration : plan.get()) { + String template = String.valueOf(codeGeneration.get("templateId")); + String modelPath = String.valueOf(codeGeneration.get("path")); + String parameters = new Gson().toJson(codeGeneration.get("parameters")); + String payload = "{\"template\":\"" + template + "\",\"parameters\":" + parameters + "}"; + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body(payload) + .when() + .post("/services/js/service-generate/generate.mjs/model/" + WORKSPACE + "/" + project + + "?path=" + modelPath) + .then() + .statusCode(201)); + } + } + + /** Generate the intent (models only) and return the non-fatal warnings from the response. */ + private List generateWarnings(String project) { + AtomicReference> warnings = new AtomicReference<>(); + restAssuredExecutor.execute(() -> warnings.set(given().when() + .post("/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + + project + "&path=app.intent") + .then() + .statusCode(200) + .extract() + .jsonPath() + .getList("warnings"))); + return warnings.get(); + } + + private void publishProject(String project) { + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/publisher/" + WORKSPACE + "/" + project + "/") + .then() + .statusCode(200)); + } + + private void writeIntent(String project, String yaml) { + String path = projectPath(project) + "/app.intent"; + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(yaml.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, yaml.getBytes(StandardCharsets.UTF_8)); + } + } + + private String contentOf(String project, String fileName) { + return new String(repository.getResource(projectPath(project) + "/" + fileName) + .getContent(), + StandardCharsets.UTF_8); + } + + private static String projectPath(String project) { + return IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + project; + } + + @AfterEach + void cleanup() { + for (String project : List.of(OWNER, CONSUMER)) { + restAssuredExecutor.execute(() -> given().when() + .delete("/services/ide/publisher/" + WORKSPACE + "/" + project) + .then() + .statusCode(greaterThanOrEqualTo(200))); + if (repository.hasCollection(projectPath(project))) { + repository.removeCollection(projectPath(project)); + } + } + } +}