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
2 changes: 1 addition & 1 deletion 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 @@ -4119,6 +4119,19 @@ private static List<Map<String, Object>> buildSchedules(IntentModel model, Map<S
+ schedule.getModel() + "] source - the schedule was NOT generated");
continue;
}
// The row query's status condition on a cross-model source (#7288): the source's
// nomenclature is seeded in the owner model, so the parser's resolver left the query
// alone - and which condition even names the status is known only here, off the owner
// .model's DOCUMENT_STATUS widget. A NAME left in it would render as a string compared
// against the integer status FK, a query that matches nothing on every tick. Refused
// the way every cross-model status site is: by seed id only.
ScheduleConditionIntent namedStatus = crossModelStatusName(schedule.getWhere(), sourceTarget);
if (namedStatus != null) {
throw new IntentValidationException(List.of("schedule [" + schedule.getName()
+ "] where-condition on the status relation [" + sourceTarget.statusProperty() + "] names the status ["
+ namedStatus.getValue() + "] of [" + entity + "], which belongs to model [" + schedule.getModel()
+ "] and is seeded there - a cross-model status must be referenced by its numeric seed id"));
}
}

Map<String, Object> entry = new LinkedHashMap<>();
Expand Down Expand Up @@ -4348,8 +4361,17 @@ private static EntityIntent crossModelRow(String entity, CrossModelSupport.Targe
* mapping and action shape).
*/
static List<Map<String, Object>> buildSchedulesForTest(IntentModel model) {
return buildSchedulesForTest(model, null);
}

/**
* Test hook: build the {@code schedules} glue collection against a context, so what the generation
* reads off a cross-model source's owner {@code .model} - its perspective, its key, and which of
* its properties is the status relation - is the real fact rather than a naming-convention default.
*/
static List<Map<String, Object>> buildSchedulesForTest(IntentModel model, IntentGenerationContext context) {
return buildSchedules(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}"),
null);
context);
}

/**
Expand Down Expand Up @@ -4620,12 +4642,33 @@ private static NotificationSupport.CrossModelLookup crossModelLookup(IntentModel
* of a unit test), in which case nothing here can tell which condition is the status one.
*/
private static ScheduleConditionIntent crossModelItemStatusName(GeneratesItemsIntent items, CrossModelSupport.TargetInfo itemSource) {
if (items == null || !items.hasWhere() || itemSource == null || itemSource.statusProperty() == null) {
return items == null || !items.hasWhere() ? null : crossModelStatusName(items.getWhere(), itemSource);
}

/**
* The condition of a cross-model row query that compares the owner's status relation with a NAME
* rather than a seed id, or null when there is none - no condition names the status, the one that
* does gives the id, or the owner model declares no status relation (or was not resolvable, the
* convention fallback of a unit test), in which case nothing here can tell which condition is the
* status one.
*
* <p>
* Shared by the two sites whose {@code { field, op, value }} triples run against a row this model
* does not own, and whose status names the parser's resolver therefore had to leave alone: a
* create-from's items rule (#7225) and a schedule's {@code where} (#7288).
*
* @param conditions the authored conditions
* @param target the owner's resolved facts
* @return the offending condition, or null
*/
private static ScheduleConditionIntent crossModelStatusName(List<ScheduleConditionIntent> conditions,
CrossModelSupport.TargetInfo target) {
if (conditions == null || target == null || target.statusProperty() == null) {
return null;
}
for (ScheduleConditionIntent condition : items.getWhere()) {
for (ScheduleConditionIntent condition : conditions) {
if (condition.getField() != null && condition.getField()
.equalsIgnoreCase(itemSource.statusProperty())
.equalsIgnoreCase(target.statusProperty())
&& !isSeedId(condition.getValue())) {
return condition;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2050,9 +2050,12 @@ private static void validateScheduleMoment(ScheduleConditionIntent condition, En
*
* <p>
* Only the status condition is checked: every other condition compares an ordinary column, where a
* string literal is just a literal. A cross-model source has no local relations to check against
* (its field references are resolved at generation time against the owner's {@code .model}), so it
* keeps the numeric-id form the same way every other cross-model status site does.
* string literal is just a literal. A cross-model source has no local relations to check against -
* neither its nomenclature nor even WHICH of the conditions names its status is knowable here - so
* it keeps the numeric-id form the same way every other cross-model status site does, and a name
* written there is refused where the owner's {@code .model} is in hand: at generation time, by
* {@code GlueIntentGenerator} (issue #7288), rather than left to render as a string compared
* against the integer status FK.
*/
private static void validateWhereStatusValue(ScheduleConditionIntent condition, EntityIntent source, String subject,
List<String> issues) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,11 @@ private void rewriteGeneratesItemsWhere(Map<?, ?> generate, String subject) {
* <p>
* Same-model source only. A cross-model source ({@code model: <uses alias>}) is not in this file's
* {@code entities}, so neither its nomenclature nor even WHICH of the conditions names its status
* is knowable here - its {@code where} field references are validated at generation time against
* the owner's {@code .model} - and it therefore keeps the numeric-id form, exactly as every other
* cross-model status site does.
* is knowable here - and it therefore keeps the numeric-id form, exactly as every other cross-model
* status site does. A name written there is not left in place to render as a string compared
* against the integer status FK: it is refused where the owner's {@code .model} is read, at
* generation time by {@code GlueIntentGenerator} (issue #7288), the same way the sibling
* cross-model {@code items: where:} rule is (#7225).
*/
private void rewriteSchedules(Map<?, ?> root) {
for (Object node : asList(root.get("schedules"))) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2981,7 +2981,8 @@ mid-nomenclature would silently retarget the query. A name that is not seeded is
and so is a value that is no status at all - never a `.eq("Status", "OVERDUE")` that matches nothing
for as long as the schedule keeps ticking. The nomenclature must be seeded in THIS model: a
cross-model source (`model: <uses alias>`) keeps the numeric seed id, as every other cross-model
status site does.
status site does - a name there is refused at Generate (the owner's `.model` is what tells which
condition names the status), so write the id.

**A `where` value may be a moment relative to now** - which is what makes the archetypal schedule, a
**staleness sweep**, expressible at all ("stuck provisioning for 30 minutes", "unanswered for a week",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;

import org.eclipse.dirigible.components.intent.model.IntentModel;
import org.eclipse.dirigible.components.intent.parser.IntentParser;
import org.eclipse.dirigible.components.intent.parser.IntentValidationException;
import org.eclipse.dirigible.repository.api.IRepository;
import org.eclipse.dirigible.repository.api.IResource;
import org.junit.jupiter.api.Test;

/**
Expand Down Expand Up @@ -611,4 +617,122 @@ void keyTermsSerializeInDeclarationOrderOnEveryJvm() {
assertEquals(List.of("kind", "property", "lower", "upper"), List.copyOf(unique.get(1)
.keySet()));
}

/**
* A dunning run over another model's invoices - the cross-model SOURCE shape ({@code model:}),
* whose nomenclature is seeded in the owner model too.
*/
private static final String CROSS_MODEL_DUNNING = """
name: dunning
uses:
- { model: invoices }
entities:
- name: DunningLetter
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: number, type: string }
- { name: sentOn, type: date }
schedules:
- name: dunning
cron: "0 0 6 * * ?"
entity: SalesInvoice
model: invoices
where:
- { field: Status, op: eq, value: OVERDUE }
generate:
to: DunningLetter
map:
Number: Number
defaults:
sentOn: now
""";

/**
* The owner model as the invoices project generated it: the status FK is the property the edm
* generator gave the {@code DOCUMENT_STATUS} widget, which is how a consumer learns WHICH property
* is the status one.
*/
private static final String OWNER_MODEL = """
{
"model": {
"entities": [
{
"name": "SalesInvoice",
"perspectiveName": "SalesInvoice",
"dataName": "INVOICES_SALESINVOICE",
"properties": [
{ "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" },
{ "name": "Number", "dataName": "NUMBER", "dataType": "VARCHAR" },
{ "name": "Status", "dataName": "STATUS_ID", "dataType": "INTEGER",
"relationshipEntityName": "SalesInvoiceStatus", "widgetType": "DOCUMENT_STATUS" }
]
}
]
}
}
""";

/**
* A cross-model source's nomenclature is seeded in the owner model, so a status NAME in the row
* query cannot resolve at parse - and used to be left in place, rendering as
* {@code .eq("Status", "OVERDUE")} against the integer status FK: a query that matched nothing for
* as long as the schedule kept ticking, with no diagnostic (dirigible #7288, the #7251 failure one
* {@code model:} key away). It is refused the way every other cross-model status site is - by seed
* id only - at the one point the owner {@code .model} tells which condition names the status.
*/
@Test
void aStatusNameOnACrossModelScheduleSourceIsRefused() {
IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_DUNNING));

IntentValidationException failure =
assertThrows(IntentValidationException.class, () -> GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context));

assertTrue(failure.getIssues()
.stream()
.anyMatch(issue -> issue.contains("[Status]") && issue.contains("[OVERDUE]") && issue.contains("[invoices]")
&& issue.contains("numeric seed id")),
"the refusal must name the relation, the name and the owner model: " + failure.getIssues());
}

/** The seed id is the cross-model form, and it renders exactly as a local query does. */
@Test
void aStatusSeedIdOnACrossModelScheduleSourceRenders() {
IntentGenerationContext context =
contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_DUNNING.replace("value: OVERDUE", "value: 4")));

Map<String, Object> s = GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context)
.get(0);

assertEquals(true, s.get("sourceCrossModel"));
assertEquals("Criteria.create().eq(\"Status\", 4)", s.get("criteriaExpression"));
// Read off the owner model, not guessed from the entity name.
assertEquals("SalesInvoice", s.get("perspective"));
}

/**
* An ordinary column compared with a string stays a string: only the status condition is refused,
* because only there is a literal a value no row can ever carry.
*/
@Test
void aStringOnANonStatusConditionOfACrossModelScheduleSourceRenders() {
IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse(
CROSS_MODEL_DUNNING.replace("{ field: Status, op: eq, value: OVERDUE }", "{ field: Number, op: eq, value: SI-1 }")));

Map<String, Object> s = GlueIntentGenerator.buildSchedulesForTest(context.getModel(), context)
.get(0);

assertEquals("Criteria.create().eq(\"Number\", \"SI-1\")", s.get("criteriaExpression"));
}

private static IntentGenerationContext contextWithOwnerModel(IntentModel model) {
IRepository repository = mock(IRepository.class);
IResource missing = mock(IResource.class);
when(missing.exists()).thenReturn(false);
IResource owner = mock(IResource.class);
when(owner.exists()).thenReturn(true);
when(owner.getContent()).thenReturn(OWNER_MODEL.getBytes(StandardCharsets.UTF_8));
when(repository.getResource(anyString())).thenReturn(missing);
when(repository.getResource("/users/admin/workspace/invoices/invoices.model")).thenReturn(owner);
return TestContexts.context(model, repository, "/users/admin/workspace/dunning", "app");
}
}
Loading