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 6b1e47ddc87..0f522c8f27b 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 @@ -1067,15 +1067,27 @@ private static List> buildGenerates(IntentModel model, Map entityNames * shape the compared field can carry. What is checked additionally is the {@code field} itself: * unlike a schedule's query, whose source may be a cross-model row or an {@code audit:} column this * model cannot see, an items rule reads a LOCAL row being cloned, so a name it does not declare - * could only ever be a condition the database rejects on the first click. + * could only ever be a condition the database rejects on the first click. The items of a + * cross-model source ({@code fromUses:}) are the exception: they live in the owner model, so their + * fields - and the status condition, which there may only give the seed id (#7225) - are checked at + * generation time against the owner's {@code .model}. * *

* {@code refuse:} requires the rule: without conditions no row is ever unqualified, so the message diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index 06d8dac2ecc..3818bab1cc3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -313,8 +313,20 @@ private void rewriteGenerates(Map root) { * symbolic - and on the ITEM row's own nomenclature, not the header's: the rule selects the rows of * the source document, so resolving a name against the document's lifecycle would take an id out of * the wrong nomenclature and quietly filter on it. + * + *

+ * A cross-model source ({@code fromUses:}) owns its items too, so their nomenclature is seeded in + * that model and unresolvable here - and a LOCAL entity of the same name must not lend its own, + * whose ids are positional in the wrong nomenclature. The conditions therefore keep the numeric-id + * form every cross-model status site keeps. WHICH of them names the status is known only to the + * owner's {@code .model}, so a name there is refused where that model is read: at generation time, + * by {@code GlueIntentGenerator} (dirigible #7225) - not left in place to render as a string + * compared against the integer status FK. */ private void rewriteGeneratesItemsWhere(Map generate, String subject) { + if (text(generate, "fromUses") != null) { + return; + } Map items = asMap(generate.get("items")); String itemEntity = items == null ? null : text(items, "from"); rewriteConditions(items == null ? null : items.get("where"), itemEntity, subject + " items where"); diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java index b906f1c1a20..376d5c36a94 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueGeneratesItemsWhereTest.java @@ -12,11 +12,18 @@ 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.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; /** @@ -213,4 +220,144 @@ void aMomentComparedWithANonTemporalFieldIsRefused() { .contains("non-temporal"), "the failure must name the shape mismatch: " + failure.getMessage()); } + + /** + * A delivery note generated from another model's goods issue, its lines from the issue's lines - + * the cross-model SOURCE shape ({@code fromUses:}), whose items are owned by the owner model too. + */ + private static final String CROSS_MODEL_YAML = """ + name: delivery-notes + uses: + - { model: inventory } + entities: + - name: DeliveryNote + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - name: DeliveryNoteItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: DeliveryNote, kind: manyToOne, to: DeliveryNote, composition: true, required: true } + generates: + - name: delivery-note-from-goods-issue + from: GoodsIssue + fromUses: inventory + to: DeliveryNote + forEntity: GoodsIssue + map: + Number: number + items: + from: GoodsIssueItem + to: DeliveryNoteItem + where: + - { field: Status, op: eq, value: APPROVED } + map: + Quantity: quantity + """; + + /** + * The owner model as the inventory project generated it: the item's 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": "GoodsIssue", + "perspectiveName": "GoodsIssue", + "dataName": "INVENTORY_GOODSISSUE", + "properties": [ + { "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" }, + { "name": "Number", "dataName": "NUMBER", "dataType": "VARCHAR" } + ] + }, + { + "name": "GoodsIssueItem", + "perspectiveName": "GoodsIssue", + "dataName": "INVENTORY_GOODSISSUEITEM", + "properties": [ + { "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" }, + { "name": "Quantity", "dataName": "QUANTITY", "dataType": "DECIMAL" }, + { "name": "GoodsIssue", "dataName": "GOODSISSUE_ID", "dataType": "INTEGER", + "relationshipType": "COMPOSITION", "relationshipEntityName": "GoodsIssue", "widgetType": "DROPDOWN" }, + { "name": "Status", "dataName": "STATUS_ID", "dataType": "INTEGER", + "relationshipEntityName": "GoodsIssueItemStatus", "widgetType": "DOCUMENT_STATUS" } + ] + } + ] + } + } + """; + + /** + * A cross-model item's nomenclature is seeded in the owner model, so a status NAME in its rule + * cannot resolve - and used to be left in place, rendering as a string compared against the integer + * status FK: a rule that matched nothing on every click, with no diagnostic (dirigible #7225). 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 aStatusNameOnACrossModelItemSourceIsRefused() { + IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_YAML)); + + IntentValidationException failure = + assertThrows(IntentValidationException.class, () -> GlueIntentGenerator.buildGeneratesForTest(context.getModel(), context)); + + assertTrue(failure.getIssues() + .stream() + .anyMatch(issue -> issue.contains("[Status]") && issue.contains("[APPROVED]") && issue.contains("[inventory]") + && 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 rule does. */ + @Test + void aStatusSeedIdOnACrossModelItemSourceRenders() { + IntentGenerationContext context = + contextWithOwnerModel(IntentParser.parse(CROSS_MODEL_YAML.replace("value: APPROVED", "value: 3"))); + + Map g = GlueIntentGenerator.buildGeneratesForTest(context.getModel(), context) + .get(0); + + assertEquals(true, g.get("crossModelSource")); + assertEquals(true, g.get("hasItems")); + assertEquals(".eq(\"Status\", 3)", g.get("itemWhere")); + // Read off the owner model, not guessed from the item's name. + assertEquals("GoodsIssue", g.get("fromItemPerspective")); + assertEquals("Id", g.get("fromItemPk")); + } + + /** + * Only the status condition is subject to the rule: the other conditions compare ordinary columns, + * where a string is just a value. + */ + @Test + void aStringOnAnOrdinaryCrossModelItemColumnIsNotAStatus() { + IntentGenerationContext context = contextWithOwnerModel(IntentParser.parse( + CROSS_MODEL_YAML.replace("- { field: Status, op: eq, value: APPROVED }", "- { field: quantity, op: gt, value: 0 }"))); + + Map g = GlueIntentGenerator.buildGeneratesForTest(context.getModel(), context) + .get(0); + + assertEquals(".gt(\"Quantity\", 0)", g.get("itemWhere")); + } + + /** + * A context whose repository serves {@link #OWNER_MODEL} as the sibling inventory project's model. + */ + 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/inventory/inventory.model")).thenReturn(owner); + return TestContexts.context(model, repository, "/users/admin/workspace/delivery-notes", "app"); + } } diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java index ed7fb0037fc..c95745a57c7 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolIntentTest.java @@ -216,6 +216,72 @@ void numericIdsAreLeftAlone() { .getFilter()); } + /** + * The items of a cross-model source ({@code fromUses:}) are seeded in the owner model, so a status + * NAME in their source-row rule is left in the numeric-id form for the generator to refuse against + * the owner's {@code .model} (dirigible #7225) - and a LOCAL entity that merely shares the item's + * name must not lend its own nomenclature to it: that id is positional in the wrong seed list. + */ + @Test + void aCrossModelItemSourceIsNotResolvedAgainstASameNamedLocalEntity() { + String yaml = """ + name: delivery-notes + uses: + - { model: inventory } + entities: + - name: LineStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: GoodsIssueItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: LineStatus, function: EntityStatus, init: 1 } + - name: DeliveryNote + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - name: DeliveryNoteItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: decimal } + relations: + - { name: DeliveryNote, kind: manyToOne, to: DeliveryNote, composition: true, required: true } + generates: + - name: delivery-note-from-goods-issue + from: GoodsIssue + fromUses: inventory + to: DeliveryNote + forEntity: GoodsIssue + map: + Number: number + items: + from: GoodsIssueItem + to: DeliveryNoteItem + where: + - { field: Status, op: eq, value: APPROVED } + map: + Quantity: quantity + seeds: + - name: line-statuses + entity: LineStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: APPROVED } + """; + IntentModel model = IntentParser.parse(yaml); + assertEquals("APPROVED", model.getGenerates() + .get(0) + .getItems() + .getWhere() + .get(0) + .getValue(), + "the local LineStatus seed id 2 must not be taken for the inventory model's APPROVED"); + } + private static void assertIssue(String yaml, String expected) { IntentValidationException thrown = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); assertTrue(thrown.getIssues()