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: 2 additions & 0 deletions .claude/docs/intent-dsl-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Property> ==|!= <literal>` 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.
Expand Down
1 change: 1 addition & 0 deletions components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Property> ==|!= <literal>` 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, <live record>)` - 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 `<condition>` 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: "<Property | Relation.field> ==|!= <literal>" | [...], status?: <gate>, message }` - it carries NO `field`/value, the condition alone.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<String, String> COMPARE_FAMILIES = Map.of("date", "date", "timestamp", "timestamp", "integer", "number", "int",
"number", "long", "number", "decimal", "number", "double", "number");
Expand Down Expand Up @@ -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;
};
}
Expand Down Expand Up @@ -261,6 +272,52 @@ public static List<Map<String, Object>> 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<Map<String, Object>> itemConditionTerms(EntityIntent items, Map<String, EntityIntent> byName, Object when) {
if (items == null) {
return null;
}
List<Map<String, Object>> terms = new ArrayList<>();
for (String term : terms(when)) {
Comparison comparison = parse(term);
Map<String, Object> 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<String, Object> itemTerm(EntityIntent items, Map<String, EntityIntent> 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<String, Object> recordTerm(EntityIntent entity, Map<String, EntityIntent> byName, Comparison comparison) {
FieldIntent field = field(entity, comparison.property());
Expand Down
Loading
Loading