Skip to content
Open
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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<ScheduleConditionIntent> where = new ArrayList<>();
private NotificationIntent notify;
private GeneratesIntent generate;
Expand Down Expand Up @@ -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<ScheduleConditionIntent> getWhere() {
return where;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -667,8 +667,23 @@ private static void validateSchedules(IntentModel model, Set<String> entityNames
.isBlank()) {
issues.add("schedule [" + name + "] has no cron expression");
}
// A cross-model source (model: <uses alias>) 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());
Expand All @@ -691,7 +706,16 @@ private static void validateSchedules(IntentModel model, Set<String> 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);
}
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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<GenerateChildIntent> children, int depth, EntityIntent source,
Set<String> entityNames, List<String> issues) {
Set<String> entityNames, Set<String> usesAliases, List<String> issues) {
if (depth > 2) {
issues.add("schedule [" + name + "] generate children nest deeper than two levels - flatten the shape");
return;
Expand Down Expand Up @@ -783,7 +807,18 @@ private static void validateGenerateChildren(String name, List<GenerateChildInte
}
} else {
String collection = String.valueOf(forEachEntity);
if (!entityNames.contains(collection)) {
// The forEach collection may itself live in another model (forEach.model: <uses alias>);
// 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()
Expand All @@ -793,7 +828,7 @@ private static void validateGenerateChildren(String name, List<GenerateChildInte
}
}
if (child.getChildren() != null) {
validateGenerateChildren(name, child.getChildren(), depth + 1, source, entityNames, issues);
validateGenerateChildren(name, child.getChildren(), depth + 1, source, entityNames, usesAliases, issues);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1396,11 +1396,54 @@ schedules:
dayField: day
```

**Rules:** unique name, a `cron`, a declared `entity`, `where` operators from the allowed list, and
**exactly one** of `notify` (valid recipient) / `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.
**Cross-model source (`model:`).** By default the `entity` is a **local** entity of this model. When
the module that owns the CREATED rows is not where the source entity lives, add `model: <uses alias>`
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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<String, Object> 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<Map<String, Object>> children = (List<Map<String, Object>>) s.get("genChildren");
assertEquals(1, children.size());
Map<String, Object> 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 = """
Expand Down
Loading
Loading