diff --git a/.claude/docs/intent-dsl-features.md b/.claude/docs/intent-dsl-features.md index 3020c8aa37f..319dd3dc776 100644 --- a/.claude/docs/intent-dsl-features.md +++ b/.claude/docs/intent-dsl-features.md @@ -10,6 +10,8 @@ Loaded on demand, not on every session: read this before changing a DSL construc **A roll-up counts the rows it is told to, and the ceiling is enforced when the document is persisted carrying its status (`rollups[].where` / `guardAt` / `message`, [#7542](https://github.com/eclipse-dirigible/dirigible/issues/7542)):** a capacity roll-up counted EVERY child, so a cancelled or voided row went on consuming the parent's capacity for ever - the fund stayed exhausted, the contract stayed committed, and the replacement document could never be issued, with every write answering 200. And the overdraw guard ran on every write of the child, which is right for a row carrying its own typed amount (an allocation's) and useless for one whose amount is a DOCUMENT TOTAL: that is recomputed from the lines after the header is written, so the guard only ever saw the 0 the header was created with and the ceiling was unenforced in exactly the case it was declared for. `where:` is the same `{ field, op, value }` triples a `schedules[].where` carries, over the CHILD's own fields and to-one relations, with a status named by its seed name like every other status site; `guardAt:` names the status the guard is enforced AT; `message:` is the refusal, with `{capacity}` / `{sum}` / `{requested}` / `{remaining}`. **The filter is ONE authored definition with TWO readers** - the asynchronous recompute's query and the synchronous re-sum the guard runs - which is what keeps the stored balance and the enforced ceiling from disagreeing by exactly the rows the author retired; the guard keeps recomputing synchronously from the child's own store and must never be "optimised" into a read of the materialised balance. It is also applied to the row in hand, so a row the filter excludes is not guarded at all: cancelling an allocation must never be refused by the very ceiling the cancellation frees. **Only `eq` / `ne`** - the filter is rendered twice (a `Criteria` clause and a Java comparison) and an exact equality is the only comparison whose two renderings cannot disagree; the Java half compares a numeric column BY VALUE (`longValue()`), the #7237 class of guard that reads as authored and is never true. A gated guard additionally runs on the TARGETED write path (`updateProperties`), which is how a workflow setter moves a document into the gate status - and #7014/#7063 keeps that write synchronous, so the refusal reaches whoever pressed the button rather than dead-lettering. Both keys are refused on a cross-model CHILD: those rows are written by the owner's repository, which is also where their guard would have to be emitted - the same reason `capacity:` itself is refused on that direction. +**A header value required when ANY line matches (`checks: requiredWhen` + `whenAnyItem:`, [#7560](https://github.com/eclipse-dirigible/dirigible/issues/7560)):** ЗДДС чл. 114 ал. 1 т. 12 - a zero-rated or reverse-charged line makes the document's legal ground a requisite, and no check kind could say so: `requiredWhen` guarded on the record's own properties (or one to-one hop), `itemsCompare` reads every line but is warning-only and compares a line to a literal, and a roll-up flag would have needed an ordering operator in `when:`. So a 0%-line invoice was issued without a legal requisite. `- { kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: ISSUED, message: ... }` is the self-contained shape (the issue's (a)): the same ` ==|!= ` grammar as `when`, read off EACH LINE of the document's items child in the repository's gate, and the header value is required when any line matches. An amount or a rate is compared **by value** (`BigDecimal.compareTo`) - the condition the kind exists for is `vatRate == 0`, and a boxed equality on a decimal column never holds. **The `status:` gate is required**: the lines are read where the document is persisted carrying it, which is also the only moment the question is complete - an ungated rule would read the lines on every REST write (where the controllers read only the row) and refuse a draft assembled line by line. `when` and `whenAnyItem` compose (ANDed). Refused at parse: a hop in a line term (a line condition reads the line itself), a property the items entity does not declare, a type an equality is not exact on, a literal that is not a value of it, an entity owning no items. Covered at every layer, runtime included - `IntentEmissionCoverageIT` posts an entry carrying a `VatRate: 0` line and is refused until the ground is given, while the entry whose lines carry no zero rate posts with none. + **`immutableInPeriod` is enforced in the repository too ([#7590](https://github.com/eclipse-dirigible/dirigible/issues/7590)):** the period lock refused a USER's write dated inside a closed period with the controller's 409 - and nothing else. A generated posting, a create-from and a schedule write through the generated repository and never meet a controller, so a sales invoice dated 2020-01-15, issued while 2020-01 was CLOSED, posted its journal entry straight into the closed period while a direct POST of the same entry was refused; every `postings:` in the fleet took that path. The generated repository now runs the same check on `save`, `update` and `updateWithoutEvent` and refuses with the same sentence as a `ValidationException` (400 over REST, the failure of the carrying unit of work elsewhere); the controller keeps its 409 and the `/{id}/mutable` pre-check. The targeted primitives are not gated - a status flip on an entry already in the period is not a booking into it. Covered by `PeriodLockRepositoryTemplateIT` and, at runtime, by `IntentEmissionCoverageIT`'s `booking-from-period` create-from, refused for the closed period and accepted for an open one. **A status the flow writes is the flow's column ([#7339](https://github.com/eclipse-dirigible/dirigible/issues/7339)):** an entity whose `function: EntityStatus` relation is moved by a `processes:` `setRelationField` step has that column refused on every generated REST surface - a create or update that sets or changes it answers **409** `'Status' changes through the workflow, not a direct edit`. Until then the column was an ordinary writable property, so a plain `PUT {"Status": 3}` moved a document straight into APPROVED with the flow bypassed end to end: no check ran, no task was ever raised, nothing the flow charges was charged, and the record read approved while the accounts knew nothing about it. `immutableWhen:` cannot close it - it locks the way OUT of a final status, while this is the way IN, from a DRAFT that is mutable by definition - and a `transitions[]` button is an ADDITIONAL guarded endpoint beside the plain PUT, not instead of it. The column is derived state owned by the flow, the same class as an `aggregate:`/roll-up target, and the flow's own writers lose nothing: a `setRelationField` step and a `transitions[]` endpoint reach the repository through the targeted `updateProperty`/`updateProperties` primitives, never through a controller. Two things are deliberately NOT refused: an **absent** value (a caller PUTs the fields its form edits, so the stored status is kept rather than erased) and a create carrying exactly the declared `init:`. A `transitions:`-only status stays writable on purpose - the button is a hand move over a status a person may also hold otherwise, and the construct that guards the other hand writes is `lifecycle:`, whose refusal would be observable from nowhere if the plain write were closed. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 5fbc903b303..055e696ff02 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -435,6 +435,7 @@ Semantics worth knowing: - **The value may be one hop away, which is the whole reason the kind exists** - the rule is about the record being sent and the address belongs to its customer. `field:` is walked by `ResolvePathSupport` (the resolver every other path in the DSL uses), so a cross-model target reads too and a path walking on PAST a cross-model relation is refused there. The hops travel into the `.model` as the check's `pathLoads` (local + null-guarded FK expression + entity + perspective + the relation's `model:` alias) and `ModelParameterProcessor.resolveCheckPathLoads` turns them into the generated `Entity`/`Repository` FQNs - it is the pass that knows the generation folder, exactly as for a master's inherited lock, and the `.model` twin could not re-derive another model's folder at all. The generated reader loads each hop by id and reads the field null-guarded, so a missing link is an EMPTY value (the check fires) rather than an NPE inside a repository. - **The `status:` gate is OPTIONAL here, unlike on the document-level kinds** - and that optionality is the routing. No gate = the rule holds on every user write, so `ModelParameterProcessor` files it with the `rowChecks` and all three controller templates (`EntityController`, `EntityMyController`, `EntityPartnerController`) enforce it in `validate()` as a 400 carrying the authored message. A gate = the repository's `enforceChecks`, like `itemsMin`, which is what puts it on #7014/#7063's SYNCHRONOUS path: the transition that sends the document reaches the gate inside the task completion, so the refusal is the 400 the Inbox shows the person who pressed the button instead of a dead-lettered incident. A gated check needs a `function: EntityStatus` relation to read the gate from (parser-enforced). - **The condition is a closed vocabulary, and it is rendered against the guarded property's DECLARED type.** `CheckSupport` owns both halves - the pattern the parser refuses on and the Java the EDM generator emits - in one class so they cannot drift. Two refusals, both about a guard that would otherwise be silently wrong: a condition the generator cannot compile is a parse ERROR (degrading it to `true`, which is what the untyped `NotificationSupport.guard` still does for a process `trigger:`, a `wait` and a `resolves:` guard, would make the value unconditionally required - a `required` nobody authored; the glue event axis stopped degrading with [#7289](https://github.com/eclipse-dirigible/dirigible/issues/7289) and shares `CheckSupport.condition` with this check), and a literal that is not a value of the property's type is refused too, because `Objects.equals(Long, int)` never holds and a `long`-column guard would switch the rule off while looking authored. Only `string`/`text`/`integer`/`int`/`long`/`boolean` and a to-one's integer FK are guardable at all; a decimal, a double or a date is compared for equality by nobody who means it. A status name in the condition resolves to its seed id like every other guard (`StatusSymbolResolver.rewriteWhen` on the check node), and the list form is an implicit AND, as in #6957. + - **`whenAnyItem:` - required when ANY LINE satisfies a condition (#7560).** ЗДДС чл. 114 ал. 1 т. 12: a zero-rated or reverse-charged line makes the document's legal ground a requisite, and no check kind reached it - `requiredWhen` guarded on the record (or one hop), `itemsCompare` reads the lines but only warns and compares a line to a literal, a roll-up `where:` would need an ordering operator in `when:`. `{ kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: ISSUED, message }` reads each line off the generated loop's `item` local: the same ` ==|!= ` grammar as `when`, over the ITEMS entity's fields and to-ones (`CheckSupport.itemConditionTerms`), widened by `DECIMAL_TYPES` - an amount or a rate compares BY VALUE through `BigDecimal.compareTo`, since a boxed equality on a decimal column never holds; `JavaLiterals.conditionExpression` renders the `decimal` term. It REQUIRES the `status:` gate: the lines are read where the document is persisted carrying it, and an ungated rule would have to read them on every REST write - where the controllers read only the row - and would refuse a draft assembled line by line. `when` and `whenAnyItem` compose (ANDed); either alone suffices. Refused: a hop in a line term (a line condition reads the line itself), a property the items entity does not declare, a type an equality is not exact on, a literal that is not a value of it, no items child. The items coordinates travel as `itemsEntity`/`itemsFk` like `itemsMin`'s, so the DAO's `Criteria` import gate (`haveItemChecks`) counts it. Unit: `IntentParserTest.aHeaderValueMayBeRequiredWhenAnyLineMatches`, `EdmIntentGeneratorTest.aRequiredWhenOverTheLinesEmitsTheItemsAndTheirTypedTerms`, `ModelParameterProcessorTest.aRequiredWhenOverTheLinesRendersItsItemConditionByValue`; render: `RequiredWhenAnyItemRepositoryTemplateIT`; runtime: `IntentEmissionCoverageIT` (`Entry.vatGround` refused at POSTED with a `VatRate: 0` line, posted with the ground, and the plain entry posted with none). - Unit: `IntentParserTest.conditionallyRequiredValuesParseAndValidate`, `EdmIntentGeneratorTest.conditionallyRequiredValuesEmitTheirConditionAndTheHopsTheirValueIsReadThrough`, `ModelParameterProcessorTest`; IT: `IntentEmissionCoverageIT` (a gated one over a hop in `EntryRepository`, an ungated one in `DocController`, and the assertion that the ungated one is NOT in the repository). - **`visibleWhen:` on a field or a composition child = status-gated visibility, UI-only (#7502).** `forbidWhen`/`locksWithMaster` only hid a panel's Add/edit/delete and `visibleTo:` gates by ROLE, so nothing hid a field or a whole detail panel until a status was reached - a DRAFT invoice rendered empty payments/copies/reminders panels. **Resolution:** `StatusSymbolResolver` resolves a FIELD's names against the record's own nomenclature and an ENTITY's against its composition MASTER's (the child is written in the master's terms, `Status != DRAFT`); ordered operators against a name are refused like every other status guard. **Validation** (`IntentParser.validateVisibleWhen`): each term through `validateGuardTerm` (field: own entity; child: the master), refused on a non-child entity, on a document's line items (`IntentEntities.documentMasters`), on a primary key and on a `required` field. **Emission:** one SCALAR `visibleWhen` (`Status != 1 && Paid == true`, model property names) on the property / entity, so it round-trips through the `.edm` without the structured-attribute lists; `PIPELINE_CLAIMED` in the audit, because `ModelParameterProcessor.resolveVisibleWhen` (`VisibleWhenLiterals`) turns it into `visibleWhenJs` (an HTML-escaped expression over `form`, an absent/`''` value reading as the property's default so a create page does not flash the field) and `visibleWhenTermsJs` (the `forbidWhen` guard shape). **Templates:** the six form/document `visibleGate` macros AND it with the role gate into the one `x-show`; the frozen header summary and the read-only details rows gate too; list-view macros deliberately do not. `detail-register` emits `visibleWhen:` and the document / form / master views gate each panel card with `basePage.visibleWhenHolds(d.visibleWhen, )` - the PARENT's `form`/`selected`, not the panel's captured `master`, which goes stale when the master page re-reads its list. The personal/partner child panels carry it through `scopedChildren`. Asserted in `EdmVisibleWhenTest`, `VisibleWhenLiteralsTest` and `IntentEmissionCoverageIT` (Entry.paid, EntryCopy). - **`checks: kind: forbidWhen` = the reject-twin of `requiredWhen` (#7275).** The shape no other check expressed: "refuse this write while `` holds", and the one reach `requiredWhen` deliberately lacked - a `when` term reading a value ONE HOP away, so a composition child can refuse a write based on its PARENT'S state (a payment allocation cannot be added to an already PAID invoice). `immutableWhen` blocks EDITING an existing record and reads its OWN status (an allocation has none), `locksWithMaster` is all-or-nothing (the allocation stays addable while the invoice is ISSUED/SENT), and the over-allocation rollup guard is a money-safety side effect, not a self-describing "the invoice is paid" rule with a domain message. Authored as `{ kind: forbidWhen, when: " ==|!= " | [...], status?: , message }` - it carries NO `field`/value, the condition alone. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java index a5ff260b9e1..4a982aefbb0 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/CheckSupport.java @@ -81,6 +81,16 @@ public final class CheckSupport { */ public static final String NULL_TEST_TYPE = "null"; + /** + * The types a condition over a document's LINES may compare besides {@link #GUARD_TYPES} (issue + * #7560): an amount or a rate, compared BY VALUE - {@code vatRate == 0} is the rule the kind exists + * for, and a boxed {@code equals} on a decimal column would never hold. + */ + public static final Set DECIMAL_TYPES = Set.of("decimal", "double"); + + /** The local a line is read off in a generated items loop. */ + public static final String ITEM = "item"; + /** The field types a {@code compare} check orders, by the family they compare inside. */ private static final Map COMPARE_FAMILIES = Map.of("date", "date", "timestamp", "timestamp", "integer", "number", "int", "number", "long", "number", "decimal", "number", "double", "number"); @@ -171,6 +181,7 @@ public static String javaLiteral(String type, String literal) { case "integer", "int" -> value.matches("-?\\d+") ? value : null; case "long" -> value.matches("-?\\d+") ? value + "L" : null; case "boolean" -> "true".equals(value) || "false".equals(value) ? value : null; + case "decimal", "double" -> value.matches("-?\\d+(\\.\\d+)?") ? "new java.math.BigDecimal(\"" + value + "\")" : null; default -> null; }; } @@ -261,6 +272,52 @@ public static List> conditionTerms(EntityIntent entity, Map< return terms.isEmpty() ? null : terms; } + /** + * Reads a condition over the document's LINES (issue #7560): each comparison names a field or a + * to-one of the ITEMS entity and is read off the {@link #ITEM} local of a generated loop. The + * record-local grammar and type rule apply, widened by {@link #DECIMAL_TYPES} - the condition the + * kind exists for is an amount or a rate against a literal, compared by value. + * + * @param items the document's items entity + * @param byName the local entities by name + * @param when the authored condition + * @return the terms, or {@code null} when there is no condition or a comparison does not read + */ + public static List> itemConditionTerms(EntityIntent items, Map byName, Object when) { + if (items == null) { + return null; + } + List> terms = new ArrayList<>(); + for (String term : terms(when)) { + Comparison comparison = parse(term); + Map read = comparison == null ? null : itemTerm(items, byName, comparison); + if (read == null) { + return null; + } + terms.add(read); + } + return terms.isEmpty() ? null : terms; + } + + /** A comparison over a line's own field or to-one, or {@code null} when it does not read. */ + private static Map itemTerm(EntityIntent items, Map byName, Comparison comparison) { + FieldIntent field = field(items, comparison.property()); + RelationIntent relation = field == null ? toOne(items, comparison.property()) : null; + if (field == null && relation == null) { + return null; + } + String property = IntentNaming.pascalCase(comparison.property()); + if (isNullTest(comparison)) { + return nullTerm(ITEM, property, comparison); + } + String type = guardType(field != null ? field.getType() : relationKeyType(relation, byName)); + if (DECIMAL_TYPES.contains(type)) { + return term(ITEM, property, comparison, "decimal", false); + } + boolean numericKey = field == null && NUMERIC_GUARD_TYPES.contains(type); + return term(ITEM, property, comparison, numericKey ? "long" : type, numericKey); + } + /** A comparison over the record's own field or to-one, or {@code null} when it does not read. */ private static Map recordTerm(EntityIntent entity, Map byName, Comparison comparison) { FieldIntent field = field(entity, comparison.property()); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 355acd793e2..551c2227088 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -2518,10 +2518,25 @@ private static List> buildChecks(EntityIntent entity, List> when = CheckSupport.conditionTerms(entity, byName, walker, check.getWhen()); - if (when == null) { + if (when == null && check.getWhenAnyItem() == null) { continue; // the parser already reported it } - checkMap.put("when", when); + if (when != null) { + checkMap.put("when", when); + } + if (check.getWhenAnyItem() != null) { + // ...and the condition over the LINES (#7560): the value is required when ANY item + // satisfies it. The items are the document's own, resolved the one way every other + // document check resolves them, and the terms read off the generated loop's local. + EntityIntent items = IntentEntities.documentItemsChild(entity.getName(), entities); + List> itemWhen = CheckSupport.itemConditionTerms(items, byName, check.getWhenAnyItem()); + if (items == null || itemWhen == null) { + continue; // the parser already reported it + } + checkMap.put("itemsEntity", items.getName()); + checkMap.put("itemsFk", IntentEntities.itemsBackReference(items, entity.getName())); + checkMap.put("whenAnyItem", itemWhen); + } List> pathLoads = pathLoadsOf(walker); if (!pathLoads.isEmpty()) { checkMap.put("pathLoads", pathLoads); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java index 75fa8a9648d..fa3a17072e6 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/CheckIntent.java @@ -112,6 +112,16 @@ public class CheckIntent { * customer). A status name resolves to its seed id, as in every other guard. */ private Object when; + + /** + * Optional, {@code requiredWhen} only (issue #7560): a condition over the document's LINES - the + * value is required when ANY item satisfies it ("the legal ground for a zero VAT rate is required + * at ISSUED when any line has {@code vatRate == 0}", ЗДДС чл. 114). The same + * {@code ==|!= } grammar as {@link #when}, read off each line; an amount or + * rate compares by value. Requires the {@link #status} gate: the lines are read where the document + * is persisted carrying it, never on every user write. + */ + private Object whenAnyItem; /** * The {@code status} gate. On the document-level checks it is required; on {@code requiredWhen} / * {@code forbidWhen} it is optional and it is the routing: without one the check holds on every @@ -207,6 +217,14 @@ public void setWhen(Object when) { this.when = when; } + public Object getWhenAnyItem() { + return whenAnyItem; + } + + public void setWhenAnyItem(Object whenAnyItem) { + this.whenAnyItem = whenAnyItem; + } + public String getSeverity() { return severity; } 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 a9a561832fd..2a7b9c3a008 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 @@ -5493,7 +5493,7 @@ private static void validateGuardOutcome(EntityIntent entity, CheckIntent check, * sends the document) names the status it is needed at. */ private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, java.util.Map byName, - String subject, List issues) { + java.util.List entities, String subject, List issues) { if (check.getField() == null || check.getField() .isBlank()) { issues.add(subject + " requires `field`: the value that must be present - a field of [" + entity.getName() @@ -5505,10 +5505,14 @@ private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, issues.add(subject + " field " + path.failure()); } } - if (check.getWhen() == null) { - issues.add( - subject + " requires `when`: the condition under which the value is required, e.g." + " `when: \"SentMethod == 1\"`"); - } else { + boolean anyItem = check.getWhenAnyItem() != null; + if (anyItem) { + validateRequiredWhenAnyItem(entity, check, byName, entities, subject, issues); + } + if (check.getWhen() == null && !anyItem) { + issues.add(subject + " requires `when`: the condition under which the value is required, e.g. `when: \"SentMethod == 1\"`" + + " - or `whenAnyItem`, a condition over the document's lines"); + } else if (check.getWhen() != null) { List terms = CheckSupport.terms(check.getWhen()); if (terms.isEmpty()) { issues.add(subject + " when must not be an empty list"); @@ -5530,6 +5534,70 @@ private static void validateRequiredWhen(EntityIntent entity, CheckIntent check, } } + /** + * The {@code whenAnyItem} half of a {@code requiredWhen} (issue #7560): a header value required + * when ANY line satisfies a condition - the legal ground a zero-rated line calls for (ЗДДС чл. + * 114). + * + *

+ * Refused where it could only be inert or wrong: an entity owning no items (nothing to read the + * lines of); no {@code status} gate (the lines are read where the document is persisted, so an + * ungated rule would have to read them on every REST write, where the generated controllers read + * only the row - and a draft assembled line by line would be refused mid-way); a term naming a hop + * (a line condition reads the line itself); a property the items entity does not declare; a type an + * equality is not exact on; a literal that is not a value of that type. + */ + private static void validateRequiredWhenAnyItem(EntityIntent entity, CheckIntent check, java.util.Map byName, + java.util.List entities, String subject, List issues) { + EntityIntent items = compositionChildOf(entity, entities); + if (items == null) { + issues.add(subject + " whenAnyItem requires the entity to own a composition child (the document's items)"); + return; + } + if (check.getStatus() == null || check.getStatus() <= 0) { + issues.add(subject + " whenAnyItem requires a `status` gate (an EntityStatus seed id) - the lines are read where the" + + " document is persisted carrying it, not on every user write"); + } + List terms = CheckSupport.terms(check.getWhenAnyItem()); + if (terms.isEmpty()) { + issues.add(subject + " whenAnyItem must not be an empty list"); + } + for (String term : terms) { + CheckSupport.Comparison comparison = CheckSupport.parse(term); + if (comparison == null) { + issues.add(subject + " whenAnyItem [" + term + "] must be ` ==|!= ` - a number, a quoted string or a" + + " bare word, read off each line"); + continue; + } + if (ResolvePathSupport.isPath(comparison.property())) { + issues.add(subject + " whenAnyItem [" + term + "] walks a relation - a line condition reads the line itself; put the" + + " value on the line"); + continue; + } + FieldIntent field = CheckSupport.field(items, comparison.property()); + RelationIntent relation = field == null ? CheckSupport.toOne(items, comparison.property()) : null; + if (field == null && relation == null) { + issues.add(subject + " whenAnyItem [" + term + "] reads [" + comparison.property() + + "], which is not a field or to-one relation of the items entity [" + items.getName() + "]"); + continue; + } + if (CheckSupport.isNullTest(comparison)) { + continue; + } + String type = CheckSupport.guardType(field != null ? field.getType() : CheckSupport.relationKeyType(relation, byName)); + if (!CheckSupport.GUARD_TYPES.contains(type) && !CheckSupport.DECIMAL_TYPES.contains(type)) { + issues.add(subject + " whenAnyItem [" + term + "] compares [" + comparison.property() + "], a [" + type + + "] - a line condition compares a string, a number, a boolean or a to-one"); + continue; + } + String literalType = CheckSupport.DECIMAL_TYPES.contains(type) ? "decimal" : relation != null ? "long" : type; + if (CheckSupport.javaLiteral(literalType, comparison.literal()) == null) { + issues.add(subject + " whenAnyItem [" + term + "] compares [" + comparison.property() + "], a [" + type + "], with [" + + comparison.literal() + "], which is not a value of that type"); + } + } + } + /** * One comparison of a typed {@code when} guard - a {@code requiredWhen} condition or the * {@code event.when} of the declarative glue axis: the property must be the record's own (the @@ -5804,7 +5872,7 @@ private static void validateCheck(EntityIntent entity, CheckIntent check, java.u return; } if ("requiredWhen".equals(kind)) { - validateRequiredWhen(entity, check, byName, subject, issues); + validateRequiredWhen(entity, check, byName, entities, subject, issues); return; } if ("forbidWhen".equals(kind)) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 9e4b22a0e27..acfd3669c7f 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -620,6 +620,13 @@ field may declare: mandatory); `whenNull: refuse` rejects the write instead. Reach for this instead of writing the rule as a `calculatedActionOnCreate`/`OnUpdate` guard class - it is the shape every allocation, transfer, timesheet and assignment entity carries. + - `{ kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: ISSUED, message: "..." }` + (#7560): a header value required when **ANY LINE** satisfies the condition - the legal ground a + zero-rated line calls for (ЗДДС чл. 114). `whenAnyItem` uses the `when` grammar over the ITEMS + entity's fields and to-ones, read off each line; an amount or a rate compares by value. It needs + the `status:` gate (the lines are read where the document is persisted carrying it, never on every + write). `when` and `whenAnyItem` may be combined (both must hold). A line term may not walk a + relation - put the value on the line. - `{ kind: requiredWhen, field: driver, when: "Status == IDENTIFIED", status: IDENTIFIED, message: "..." }` (#7094): a **conditionally required** value - `field` must be present whenever `when` holds. `field` is the entity's own field or a one-hop `Relation.field`; `when` is a guard (see *the event diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 02c4b11137e..b2ddf05d717 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -3669,4 +3669,56 @@ private static String guardJava(Map check) { private static String literalJava(Map check) { return JavaLiterals.compareLiteralExpression((Map) check.get("value")); } + + /** + * A {@code requiredWhen} over the LINES (#7560) carries the items coordinates and its item terms as + * data - an amount or a rate typed {@code decimal}, compared by value in the template layer. + */ + @Test + @SuppressWarnings("unchecked") + void aRequiredWhenOverTheLinesEmitsTheItemsAndTheirTypedTerms() { + String yaml = """ + name: billing + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + checks: + - { kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: ISSUED, + message: "A zero-rated line needs its legal ground (VAT Act art. 114)" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: vatGround, type: string, length: 200 } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: DRAFT } + - { name: lines, kind: oneToMany, to: InvoiceLine } + - name: InvoiceLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: vatRate, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "billing"); + List> checks = (List>) entityByName(entities(model), "Invoice").get("checks"); + assertEquals(1, checks.size()); + Map check = checks.get(0); + assertEquals("InvoiceLine", check.get("itemsEntity")); + assertEquals("Invoice", check.get("itemsFk")); + assertEquals("2", check.get("status")); + assertEquals("Status", check.get("statusProperty")); + assertNull(check.get("when"), "no record-local condition was authored"); + assertEquals(List.of( + Map.of("owner", "item", "property", "VatRate", "equal", true, "type", "decimal", "value", "0", "numericKey", false)), + check.get("whenAnyItem")); + } } 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 7f08a1b6681..1db33f7c66c 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 @@ -3434,6 +3434,68 @@ void relatedRejectsAnUnrelatedSourceAnUnknownColumnAndACompositionChild() { "a composition child is already an editable collection, got: " + ex.getIssues()); } + /** + * A header value required when ANY line satisfies a condition (#7560) - the legal ground a + * zero-rated line calls for (ЗДДС чл. 114). Read where the lines are, so it needs the gate. + */ + @Test + void aHeaderValueMayBeRequiredWhenAnyLineMatches() { + String yaml = """ + name: billing + seeds: + - name: invoice-statuses + entity: InvoiceStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: ISSUED } + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + checks: + - { kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: ISSUED, + message: "A zero-rated line needs its legal ground (VAT Act art. 114)" } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: vatGround, type: string, length: 200 } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: DRAFT } + - { name: lines, kind: oneToMany, to: InvoiceLine } + - name: InvoiceLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: vatRate, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + """; + IntentModel model = IntentParser.parse(yaml); + assertEquals(2, model.getEntities() + .get(1) + .getChecks() + .get(0) + .getStatus(), + "the gate resolves the seeded name to its id"); + + java.util.function.Function refusalOf = + broken -> assertThrows(IntentValidationException.class, () -> IntentParser.parse(broken)).getMessage(); + // The lines are read where the document is persisted carrying its status, never on every write. + assertTrue(refusalOf.apply(yaml.replace(", status: ISSUED", "")) + .contains("whenAnyItem requires a `status` gate")); + // A line condition reads the line itself. + assertTrue(refusalOf.apply(yaml.replace("vatRate == 0", "Invoice.vatGround == 'x'")) + .contains("walks a relation")); + assertTrue(refusalOf.apply(yaml.replace("vatRate == 0", "vatRatio == 0")) + .contains("is not a field or to-one relation of the items entity [InvoiceLine]")); + assertTrue(refusalOf.apply(yaml.replace("vatRate == 0", "vatRate == 'zero'")) + .contains("which is not a value of that type")); + // Nothing to read the lines of. + assertTrue(refusalOf.apply(yaml.replace("composition: true, ", "")) + .contains("requires the entity to own a composition child")); + } + /** * {@code defaultValue: now} (#7603) is the current moment in the field's own shape, so it parses on * the four fields that hold one and is refused on any other - where it would be stored as the text diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java index ea9e45c8bf0..07c95d83b75 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/JavaLiterals.java @@ -225,8 +225,11 @@ public static String conditionExpression(List> terms) { if (literal == null) { return null; // a term the generator did not type - never rendered as a weaker guard } - String comparison = flag(term, "numericKey") ? "(" + access + " != null && " + access + ".longValue() == " + literal + ")" - : "java.util.Objects.equals(" + access + ", " + literal + ")"; + String comparison = "decimal".equals(text(term, "type")) + ? "(" + access + " != null && new java.math.BigDecimal(String.valueOf(" + access + ")).compareTo(" + literal + + ") == 0)" + : flag(term, "numericKey") ? "(" + access + " != null && " + access + ".longValue() == " + literal + ")" + : "java.util.Objects.equals(" + access + ", " + literal + ")"; test = equal ? comparison : "!" + comparison; } if (expression.length() > 0) { @@ -463,6 +466,8 @@ private static String guardLiteral(String type, String value) { case "long" -> value.matches("-?\\d+") ? value + "L" : null; case "number" -> value.matches("-?\\d+(\\.\\d+)?") ? value : null; case "boolean" -> "true".equals(value) || "false".equals(value) ? value : null; + // An amount or a rate (#7560), compared by value against the column's own BigDecimal. + case "decimal" -> value.matches("-?\\d+(\\.\\d+)?") ? "new java.math.BigDecimal(\"" + value + "\")" : null; default -> null; }; } diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java index c5b688894a8..58d286113ce 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessor.java @@ -441,6 +441,15 @@ private static void resolveCheckJavaExpressions(Map check) { check.put("guardJavaExpression", expression); } } + // The condition over the document's LINES (#7560), read off the `item` local of the loop the + // repository renders around it. + List> whenAnyItem = asMaps(check.get("whenAnyItem")); + if (!whenAnyItem.isEmpty()) { + String expression = JavaLiterals.conditionExpression(whenAnyItem); + if (expression != null) { + check.put("anyItemJavaExpression", expression); + } + } } /** diff --git a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java index 97643ca3036..3cfb2e6c526 100644 --- a/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java +++ b/components/ide/ide-template/src/test/java/org/eclipse/dirigible/components/ide/template/service/model/ModelParameterProcessorTest.java @@ -1610,4 +1610,35 @@ private static Map parameters() { return parameters; } + /** The condition over the lines (#7560) renders against the loop's `item`, a decimal by value. */ + @Test + void aRequiredWhenOverTheLinesRendersItsItemConditionByValue() { + Map check = new LinkedHashMap<>(); + check.put("kind", "requiredWhen"); + check.put("status", "2"); + check.put("statusProperty", "Status"); + check.put("valueExpression", "entity.VatGround"); + check.put("message", "needs a ground"); + Map term = new LinkedHashMap<>(); + term.put("owner", "item"); + term.put("property", "VatRate"); + term.put("equal", Boolean.TRUE); + term.put("type", "decimal"); + term.put("value", "0"); + term.put("numericKey", Boolean.FALSE); + check.put("whenAnyItem", List.of(term)); + Map entity = entity("Invoice", "Invoices", property("VatGround", "VARCHAR")); + entity.put("checks", List.of(check)); + Map parameters = parameters(); + parameters.put("javaRuntime", Boolean.TRUE); + + ModelParameterProcessor.process(model(entity), parameters); + + assertEquals( + "((item == null ? null : item.VatRate) != null && new java.math.BigDecimal(String.valueOf((item == null ? null : item.VatRate)))" + + ".compareTo(new java.math.BigDecimal(\"0\")) == 0)", + check.get("anyItemJavaExpression")); + assertNull(check.get("guardJavaExpression"), "no record-local condition was authored"); + assertEquals(List.of(check), entity.get("documentChecks"), "gated, so the repository's"); + } } diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template index dc0d5a78310..74159696c23 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template @@ -286,7 +286,7 @@ package gen.${javaGenFolderName}.data.${javaPerspectiveName}; ## model that declares nothing else. #if($documentChecks) #foreach($check in $documentChecks) -#if($check.kind == "itemsSumEqual" || $check.kind == "itemsMin") +#if($check.kind == "itemsSumEqual" || $check.kind == "itemsMin" || $check.anyItemJavaExpression) #set($haveItemChecks = "true") #end #end @@ -1198,9 +1198,27 @@ public class ${name}Repository extends JavaRepository<${name}Entity>#if($hasTarg ${load.entityClass} ${load.local} = ${load.local}Fk == null ? null : (${load.entityClass}) loadedHops.computeIfAbsent("${load.repositoryClass}:" + ${load.local}Fk, hopKey -> new ${load.repositoryClass}().findById(${load.local}Fk)); +#end +#if($check.anyItemJavaExpression) + // ...required when ANY line satisfies the condition (#7560) - the legal ground a zero-rated + // line calls for. The lines are read here, where the document is persisted carrying the gate. + boolean anyItem${foreach.count} = false; + for (${check.itemsEntity}Entity item : new ${check.itemsEntity}Repository().findAll( + Criteria.create().eq("${check.itemsFk}", entity.${pkPropertyName}))) { + if (${check.anyItemJavaExpression}) { + anyItem${foreach.count} = true; + break; + } + } +#set($requiredGuard = "anyItem" + $foreach.count) +#if($check.guardJavaExpression) +#set($requiredGuard = "(" + $check.guardJavaExpression + ") && anyItem" + $foreach.count) +#end +#else +#set($requiredGuard = $check.guardJavaExpression) #end Object requiredValue = ${check.valueExpression}; - if ((${check.guardJavaExpression}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { + if ((${requiredGuard}) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) { throw org.eclipse.dirigible.sdk.db.CheckMessages.refusal(${check.messageArgsJava}); } #elseif($check.kind == "forbidWhen") diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index 46f517e7a82..2ae7a10d1ec 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -251,6 +251,11 @@ class IntentEmissionCoverageIT extends IntegrationTest { # gated check above uses, and enforced by the controller on every write. - { kind: requiredWhen, field: note, when: ["Account.taxCode == 'EXEMPT'", "Account.Parent != 0"], message: "An entry against a tax-exempt account must say why in its note" } + # ...and required when ANY LINE satisfies a condition (#7560): the legal ground a + # zero-rated line calls for (ЗДДС чл. 114). Gated, since the lines are read where the + # document is persisted; the rate is a decimal, compared by value. + - { kind: requiredWhen, field: vatGround, whenAnyItem: "vatRate == 0", status: 2, + message: "A zero-rated line needs its legal ground (VAT Act art. 114)" } # Two values of the SAME row, related (#7095) - one temporal pair and one numeric, # the two comparison families the generated code emits differently. - { kind: compare, field: due, op: ge, than: date, message: 'A "due" date is never before the entry date' } @@ -269,6 +274,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { # POSTED - a status name, resolved against the entry's own nomenclature. - { name: paid, type: decimal, visibleWhen: "Status == POSTED" } - { name: note, type: string, length: 200 } + - { name: vatGround, type: string, length: 200 } relations: - { name: Account, kind: manyToOne, to: Account, leafOnly: true } - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } @@ -408,6 +414,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { # is what makes the generated handler call the default-aware comparison helper, and so # what makes it emit that helper's method at all (#7177). - { name: weight, type: decimal, defaultValue: 0 } + - { name: vatRate, type: decimal } # #6336 on a document ITEM: the pattern must reach the item-dialog column metadata. - { name: reference, type: string, length: 20, pattern: '^[A-Z]{3}-[0-9]{4}$' } # conditional dependsOn (#6358): the copied Unit property is picked by the open @@ -2493,6 +2500,13 @@ private void assertEmission() { && entryRepository.contains("AccountRepository().findById(hop0Fk)") && entryRepository.contains("java.util.Objects.equals(entity.Note, \"audited\")"), "checks: requiredWhen must load the hop, test the condition and refuse the empty value, got: " + entryRepository); + // ...and required when ANY line satisfies the condition (#7560): the repository reads the lines + // at the gate and tests the rate BY VALUE - a boxed equality on a decimal never holds. + assertTrue( + entryRepository.contains("A zero-rated line needs its legal ground (VAT Act art. 114)") + && entryRepository.contains("for (EntryLineEntity item : new EntryLineRepository().findAll(") + && entryRepository.contains("compareTo(new java.math.BigDecimal(\"0\")) == 0"), + "checks: requiredWhen whenAnyItem must read the lines at the gate and compare the rate by value, got: " + entryRepository); // ...and every check walking the same relation shares ONE load of its row per call (#7526): the // repository declares a single per-call map, and each check's hop goes through it keyed by the // repository and the foreign key - N checks over Account.* cost one findById, not N. @@ -5296,6 +5310,48 @@ private void assertRuntimeEnforcement() { .then() .statusCode(200)); + // checks: requiredWhen over the LINES, at runtime (#7560): a zero-rated line makes the header's + // legal ground required at POSTED - refused without it, posted with it, and an entry whose lines + // carry no zero rate is posted with no ground at all (the plain entry above). + AtomicInteger zeroRatedEntry = new AtomicInteger(); + restAssuredExecutor.execute(() -> zeroRatedEntry.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-23\",\"Account\":" + uncodedAccount.get() + + "}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Entry\":" + zeroRatedEntry.get() + ",\"Debit\":10,\"VatRate\":0}") + .when() + .post(API + "/entry/EntryLineController") + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Entry\":" + zeroRatedEntry.get() + ",\"Credit\":10,\"VatRate\":20}") + .when() + .post(API + "/entry/EntryLineController") + .then() + .statusCode(200)); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + zeroRatedEntry.get() + ",\"Date\":\"2026-01-23\",\"Account\":" + + uncodedAccount.get() + ",\"Status\":2}") + .when() + .put(API + "/entry/EntryController/" + zeroRatedEntry.get()) + .then() + .statusCode(400) + .body("message", containsString("A zero-rated line needs its legal ground"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"Id\":" + zeroRatedEntry.get() + ",\"Date\":\"2026-01-23\",\"Account\":" + + uncodedAccount.get() + + ",\"VatGround\":\"ЗДДС чл. 114, ал. 1, т. 12\",\"Status\":2}") + .when() + .put(API + "/entry/EntryController/" + zeroRatedEntry.get()) + .then() + .statusCode(200)); + // checks: requiredWhen with its CONDITION one hop away, at runtime (#7495): the note is // required only against an account whose own tax code says it is exempt - the rule is decided // by the RELATED row, so the same note-less entry is refused against one account and accepted diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/RequiredWhenAnyItemRepositoryTemplateIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/RequiredWhenAnyItemRepositoryTemplateIT.java new file mode 100644 index 00000000000..318d556f05d --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/RequiredWhenAnyItemRepositoryTemplateIT.java @@ -0,0 +1,144 @@ +/* + * 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 org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.engine.template.velocity.VelocityGenerationEngine; +import org.junit.jupiter.api.Test; + +/** + * A {@code requiredWhen} over the document's LINES (dirigible #7560), rendered through the + * platform's {@link VelocityGenerationEngine}: the repository's gate reads the lines, tests each + * against the authored condition and refuses an empty header value when any line matches - the + * legal ground a zero-rated line calls for. + * + *

+ * Rendering needs nothing from a running instance, so this boots no application context. + */ +class RequiredWhenAnyItemRepositoryTemplateIT { + + private static final String DAO_BASE = "/META-INF/dirigible/template-application-dao-java/data/"; + private static final String ANY_ITEM = + "((item == null ? null : item.VatRate) != null && new java.math.BigDecimal(String.valueOf((item == null ? null : item.VatRate))).compareTo(new java.math.BigDecimal(\"0\")) == 0)"; + + private final VelocityGenerationEngine velocityGenerationEngine = new VelocityGenerationEngine(); + + @Test + void theGateReadsTheLinesAndRequiresTheValueWhenAnyMatches() throws Exception { + String rendered = render(context(check(null))); + + assertTrue(rendered.contains("if (entity.Status != null && entity.Status == 2) {"), "gated on the authored status: " + rendered); + assertTrue(rendered.contains("for (InvoiceLineEntity item : new InvoiceLineRepository().findAll("), + "the lines must be read at the gate: " + rendered); + assertTrue(rendered.contains("Criteria.create().eq(\"Invoice\", entity.Id)"), "the document's own lines: " + rendered); + assertTrue(rendered.contains("if (" + ANY_ITEM + ") {"), "each line tested against the authored condition: " + rendered); + assertTrue(rendered.contains("if ((anyItem1) && (requiredValue == null || String.valueOf(requiredValue).isBlank())) {"), + "the value is required when any line matched: " + rendered); + assertTrue(rendered.contains("import org.eclipse.dirigible.components.data.store.java.repository.Criteria;"), + "the items query needs Criteria: " + rendered); + } + + /** A record-local condition alongside it is ANDed - both must hold for the value to be required. */ + @Test + void aRecordConditionAndTheLineConditionAreBothRequired() throws Exception { + String rendered = render(context(check("java.util.Objects.equals(entity.Kind, \"export\")"))); + + assertTrue(rendered.contains("if (((java.util.Objects.equals(entity.Kind, \"export\")) && anyItem1) && (requiredValue == null"), + "the two conditions are ANDed: " + rendered); + } + + /** A requiredWhen without a line condition renders exactly as it did before #7560. */ + @Test + void aRequiredWhenWithoutALineConditionReadsNoLines() throws Exception { + Map check = check("java.util.Objects.equals(entity.Kind, \"export\")"); + check.remove("anyItemJavaExpression"); + check.remove("itemsEntity"); + check.remove("itemsFk"); + String rendered = render(context(check)); + + assertFalse(rendered.contains("InvoiceLineRepository"), "no lines are read: " + rendered); + assertTrue(rendered.contains("if ((java.util.Objects.equals(entity.Kind, \"export\")) && (requiredValue == null"), + "the record-local condition alone: " + rendered); + } + + private String render(Map parameters) throws Exception { + String location = DAO_BASE + "Repository.java.template"; + String template; + try (InputStream in = getClass().getResourceAsStream(location)) { + assertNotNull(in, "template resource not found on classpath: " + location); + template = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + byte[] out = velocityGenerationEngine.generate(parameters, location, template.getBytes(StandardCharsets.UTF_8)); + return new String(out, StandardCharsets.UTF_8); + } + + /** The gated check as ModelParameterProcessor hands it to the template. */ + private static Map check(String guardJavaExpression) { + Map check = new LinkedHashMap<>(); + check.put("kind", "requiredWhen"); + check.put("status", "2"); + check.put("statusProperty", "Status"); + check.put("valueExpression", "entity.VatGround"); + check.put("label", "VatGround"); + check.put("messageArgsJava", "\"sales:sales-model.checks.Invoice_requiredWhen_0\", \"A zero-rated line needs its legal ground\""); + check.put("itemsEntity", "InvoiceLine"); + check.put("itemsFk", "Invoice"); + check.put("anyItemJavaExpression", ANY_ITEM); + if (guardJavaExpression != null) { + check.put("guardJavaExpression", guardJavaExpression); + } + return check; + } + + private static Map context(Map check) { + Map parameters = new HashMap<>(); + parameters.put("name", "Invoice"); + parameters.put("projectName", "billing"); + parameters.put("perspectiveName", "Invoice"); + parameters.put("javaGenFolderName", "billing"); + parameters.put("javaPerspectiveName", "invoice"); + parameters.put("tablePrefix", "BILLING_"); + parameters.put("dataName", "INVOICE"); + parameters.put("pkPropertyName", "Id"); + parameters.put("properties", + List.of(primaryKey(), property("Status", "INTEGER", "Integer"), property("VatGround", "VARCHAR", "String"))); + parameters.put("sensitiveProperties", List.of()); + parameters.put("documentChecks", List.of(check)); + return parameters; + } + + private static Map primaryKey() { + Map property = property("Id", "INTEGER", "Integer"); + property.put("dataPrimaryKey", Boolean.TRUE); + property.put("dataNotNull", Boolean.TRUE); + return property; + } + + private static Map property(String name, String dataType, String javaClass) { + Map property = new LinkedHashMap<>(); + property.put("name", name); + property.put("dataName", "INVOICE_" + name.toUpperCase()); + property.put("dataType", dataType); + property.put("dataTypeJavaClass", javaClass); + property.put("dataPrimaryKey", Boolean.FALSE); + property.put("dataNotNull", Boolean.FALSE); + return property; + } +}