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
Original file line number Diff line number Diff line change
Expand Up @@ -1067,15 +1067,27 @@ private static List<Map<String, Object>> buildGenerates(IntentModel model, Map<S
// document's perspective. (The TARGET item stays on toPerspective: a create-from
// always writes into the target document's own composition-item table.)
// A cross-model source's items are owned by the same foreign model as the source.
e.put("fromItemPerspective", crossModelSource ? CrossModelSupport.resolve(context, fromUses, items.getFrom())
.perspectiveName()
CrossModelSupport.TargetInfo itemSource =
crossModelSource ? CrossModelSupport.resolve(context, fromUses, items.getFrom()) : null;
// The source-row rule's status condition on a cross-model item (#7225): the item's
// nomenclature is seeded in the owner model, so the parser's resolver left the rule
// 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 rule that matches nothing on every click. Refused
// the way every cross-model status site is: by seed id only.
ScheduleConditionIntent namedStatus = crossModelItemStatusName(items, itemSource);
if (namedStatus != null) {
throw new org.eclipse.dirigible.components.intent.parser.IntentValidationException(List.of("generates [" + g.getName()
+ "] items where-condition on the status relation [" + itemSource.statusProperty() + "] names the status ["
+ namedStatus.getValue() + "] of [" + items.getFrom() + "], which belongs to model [" + g.getFromUses()
+ "] and is seeded there - a cross-model status must be referenced by its numeric seed id"));
}
e.put("fromItemPerspective", itemSource != null ? itemSource.perspectiveName()
: IntentEntities.resolvePerspective(items.getFrom(), compositionParents, model));
// The source line's own key, so a line the target refuses is reported with the row it came
// from ("... from EmployeeTimesheet [7]") instead of the target property alone - which of a
// hundred lines is missing a value is the whole question the caller has (#7069).
e.put("fromItemPk", crossModelSource ? CrossModelSupport.resolve(context, fromUses, items.getFrom())
.keyField()
: IntentEntities.keyFieldName(byName.get(items.getFrom())));
e.put("fromItemPk", itemSource != null ? itemSource.keyField() : IntentEntities.keyFieldName(byName.get(items.getFrom())));
// A document child's FK back to its master is, by convention, the master entity's name.
e.put("srcFkProperty", IntentNaming.pascalCase(g.getFrom()));
e.put("toFkProperty", IntentNaming.pascalCase(g.getTo()));
Expand Down Expand Up @@ -4601,6 +4613,43 @@ private static NotificationSupport.CrossModelLookup crossModelLookup(IntentModel
* 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.
*/
/**
* The condition of a cross-model items rule that compares the item's status relation with a NAME
* rather than a seed id (#7225), or null when there is none - no rule, a rule that gives the id, or
* an owner model that 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.
*/
private static ScheduleConditionIntent crossModelItemStatusName(GeneratesItemsIntent items, CrossModelSupport.TargetInfo itemSource) {
if (items == null || !items.hasWhere() || itemSource == null || itemSource.statusProperty() == null) {
return null;
}
for (ScheduleConditionIntent condition : items.getWhere()) {
if (condition.getField() != null && condition.getField()
.equalsIgnoreCase(itemSource.statusProperty())
&& !isSeedId(condition.getValue())) {
return condition;
}
}
return null;
}

/** Whether a where value is a whole number - as an id, or as the text of one. */
private static boolean isSeedId(Object value) {
if (value instanceof Number number) {
return number.longValue() == number.doubleValue();
}
if (value == null) {
return false;
}
try {
Long.parseLong(String.valueOf(value)
.trim());
return true;
} catch (NumberFormatException ex) {
return false;
}
}

private static void reportDroppedGlue(IntentGenerationContext context, String message) {
LOGGER.warn(LoggedValue.of(message));
if (context != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7530,7 +7530,10 @@ private static void validateGenerates(IntentModel model, Set<String> 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}.
*
* <p>
* {@code refuse:} requires the rule: without conditions no row is ever unqualified, so the message
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>
* 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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<String, Object> 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<String, Object> 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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading