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-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt

**A value required only under a condition (`checks: requiredWhen`, [#7094](https://github.com/eclipse-dirigible/dirigible/issues/7094)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - none of them says "the customer's e-mail address must be there when Sent Method is E-mail", and `required` is unconditional, so the rule had no form: an invoice sent by e-mail to a customer carrying no address went through Send with status SENT, the mail step logged a no-op for a missing recipient, and the clerk who pressed the button was told nothing. The module's alternative was a delegate plus a decision plus a hold task plus a form - about fifteen intent lines and a Java class for one sentence of rule - and it landed the clerk on a hold task instead of a refusal on the button they pressed. `- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT, message: ... }`. **The value may be one hop away**, which is the reason the kind exists at all: `field:` is a field of the record or a `Relation.field` over a to-one - cross-model included, walked by the same resolver every other path in the DSL uses - and the generated reader loads that row by FK first, null-guarded, so a missing link is an empty value the check fires on rather than a throw inside a repository. **The `status:` gate is optional, and its presence is the routing**: without one the rule holds on every user write (each generated controller's `validate()`, a 400 with the authored message, like `exactlyOne`), with one it is the repository's, like `itemsMin` - which puts it on the synchronous path #7014/#7063 opened, so the refusal reaches the person completing the task instead of dead-lettering as a process incident. **The condition is closed and typed**: one or more `<Property> ==|!= <literal>` comparisons over the record's own properties (ANDed, as in #6957, with a status name resolved to its seed id like every other guard), refused at parse when it does not compile - degrading it to "true" would make the value unconditionally required, a `required` nobody authored - and refused when the literal is not a value of the property's declared type, because `Objects.equals(Long, int)` never holds and a guard on a `long` column would switch the rule off while looking authored. Only strings, integers, booleans and a to-one's key are guardable; a decimal, a double or a date is compared for equality by nobody who means it. Details in the engine-intent guide's requiredWhen bullet.

**Two values of one row, related (`checks: compare`, [#7095](https://github.com/eclipse-dirigible/dirigible/issues/7095)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - nothing compared two fields of the SAME record, so "a due date is never before the invoice date" was not expressible and a document was saved (200), issued and overdue the moment it existed; the module's workaround was a `calculatedActionOnCreate`/`OnUpdate` class per document type that silently CORRECTED the date instead of refusing it, which is a different thing and never tells the clerk. `- { kind: compare, field: due, op: ge, than: date, message: ... }` is row-level like `exactlyOne`: enforced in every generated controller's `validate()` (the entity, personal and partner surfaces) as a 400 carrying the authored message, and therefore taking no `status` gate - a rule about two values of one row holds from the first save, not from a transition. `op:` is `ge`/`gt`/`le`/`lt`/`eq`/`ne`, spelled out because an omitted operator has no defensible default. Both operands are the entity's own **fields** - a comparison of two foreign keys means nothing - and must sit in ONE comparison family, which is what the generated code needs: two temporals compare through their own `compareTo` (a `LocalDate` does not compare to an `Instant`), two numbers by value through `BigDecimal` so a `decimal` against a `long` stays exact. Only dates, timestamps and numbers compare; a string / `month` / `week` is refused rather than silently ordered lexicographically, as is a field-with-itself. An **absent operand is not a violation** - a comparison is about two values that exist, and requiredness is its own declaration.

**Deleting a header deletes the lines it owns (`whenMasterDeleted:`, [#7100](https://github.com/eclipse-dirigible/dirigible/issues/7100)):** a deleted master left its composition children behind - rows pointing at an id that no longer exists, invisible in the UI (no parent page renders them) and still counted by every report and roll-up over the child, so a deleted vacation request's five days kept the entitlement EXHAUSTED. The cascade is now emitted for EVERY composition master, because it is what composition MEANS: the master's generated repository deletes the children at the head of `delete`/`deleteById`, in the same transaction and through each child's OWN repository, so the child's `-deleted` event (hence the roll-up relinquishing), its history trail and its own cascade all run - a deep chain unwinds level by level. The reverse index this needs (`CompositionChildren` in `ide-template`) is DERIVED from the child's `masterEntity`/`masterEntityId`, so a hand-authored `.edm` gets it too. The author's alternative is `whenMasterDeleted: refuse` on the child's composition relation - the same method rejects the master's delete while any child exists, naming both entities - which is refused at parse on a non-composition and on a SECOND composition (the EDM emits that one as a plain association, so the key would ask for a cascade nothing would run). `cascade` is the default and emits no `.edm` attribute, so an untouched model is byte-identical. This is the data-side half of the process-side `whenDeleted: abort | refuse` (#7074).

**The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats.
Expand Down
4 changes: 2 additions & 2 deletions components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Values: `Document`, `DocumentItem`, `Master`, `Detail`, `List`, `Setting` (entit

## checks - declarative validations

Row-level `exactlyOne` and `requiredWhen` on every user write; document-level `itemsMin` /
Row-level `exactlyOne`, `compare` and `requiredWhen` on every user write; document-level `itemsMin` /
`itemsSumEqual` gated on a status transition (drafting stays unconstrained; the failing transition aborts with the authored
message). A document-level check counts the document's LINES: a child flagged
`function: DocumentItem`, else the `*Item`-named child, else the sole composition child, else the
Expand All @@ -130,8 +130,15 @@ first declared. Flag the lines child explicitly on a document that owns several
- name: JournalEntryItem
checks:
- { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit/credit" }
- name: SalesInvoice
checks:
- { kind: compare, field: due, op: ge, than: date, message: "Due cannot be before the invoice date" }
```

`compare` relates two values of the same row: `op:` is `ge` / `gt` / `le` / `lt` / `eq` / `ne`, both
operands are the entity's own fields, and both must be dates, both timestamps or both numbers. An
absent operand is not a violation - requiredness is its own declaration.

`requiredWhen` is a value that is required only under a condition - the rule `required` cannot
express, because the value is needed for one way of handling the record and meaningless for the
others. The value may be the record's own field or a one-hop `Relation.field` (the target may be
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
// exactlyOne checks: exactly one of the named fields may be non-null - a sample record
// filling all of them is rejected with 400, so the runner keeps only the first
List<List<String>> exactlyOne = new ArrayList<>();
// compare checks: two of the record's own fields must stand in a relation - the sample values
// are per-type constants, so two dates come out EQUAL and a strict comparison (gt/lt/ne) would
// reject the sample record with 400. The runner derives the left operand from the right.
List<Map<String, Object>> compare = new ArrayList<>();
for (CheckIntent check : entity.getChecks() == null ? List.<CheckIntent>of() : entity.getChecks()) {
if ("exactlyOne".equals(check.getKind()) && check.getFields() != null && !check.getFields()
.isEmpty()) {
Expand All @@ -244,10 +248,22 @@ private static Map<String, Object> entityManifest(EntityIntent entity, Map<Strin
.map(IntentNaming::pascalCase)
.toList());
}
if ("compare".equals(check.getKind()) && check.getField() != null && check.getThan() != null && check.getOp() != null) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("field", IntentNaming.pascalCase(check.getField()));
entry.put("op", check.getOp()
.trim()
.toLowerCase(java.util.Locale.ROOT));
entry.put("than", IntentNaming.pascalCase(check.getThan()));
compare.add(entry);
}
}
if (!exactlyOne.isEmpty()) {
out.put("exactlyOne", exactlyOne);
}
if (!compare.isEmpty()) {
out.put("compare", compare);
}
out.put("fields", fields(entity));
List<Map<String, Object>> relations = relations(entity, model, context, edmEntities);
if (!relations.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,20 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
.stream()
.map(IntentNaming::pascalCase)
.toList());
} else if ("compare".equals(check.getKind())) {
// Two values of the same row, compared (#7095). The template gets the two PascalCased
// properties, the Java comparison operator the compareTo result is tested with, and
// whether the two are numbers - two temporals compare through compareTo, two numbers
// by value through BigDecimal so a decimal and a long still compare exactly.
String comparison = compareOperator(check.getOp());
Boolean numeric = isNumericCompare(entity, check);
if (check.getField() == null || check.getThan() == null || comparison == null || numeric == null) {
continue; // the parser already reported it
}
checkMap.put("field", IntentNaming.pascalCase(check.getField()));
checkMap.put("than", IntentNaming.pascalCase(check.getThan()));
checkMap.put("op", comparison);
checkMap.put("numeric", numeric ? "true" : "false");
} else {
// The document's LINES - the shared resolution, so the guard counts the rows the
// document layout renders. Scanning for "some composition child" made a multi-child
Expand Down Expand Up @@ -2299,6 +2313,57 @@ private static List<Map<String, Object>> buildChecks(EntityIntent entity, List<E
return checkMaps;
}

/** The Java comparison the {@code compareTo} result is tested with, or null for an unknown op. */
private static String compareOperator(String op) {
if (op == null) {
return null;
}
return switch (op.trim()
.toLowerCase(java.util.Locale.ROOT)) {
case "ge" -> ">=";
case "gt" -> ">";
case "le" -> "<=";
case "lt" -> "<";
case "eq" -> "==";
case "ne" -> "!=";
default -> null;
};
}

/**
* Whether a {@code compare} check's two fields are numbers (rather than temporals), or null when
* either field or its type does not resolve - the parser has already reported that.
*/
private static Boolean isNumericCompare(EntityIntent entity, org.eclipse.dirigible.components.intent.model.CheckIntent check) {
FieldIntent left = fieldOf(entity, check.getField());
FieldIntent right = fieldOf(entity, check.getThan());
if (left == null || right == null) {
return null;
}
boolean leftNumeric = isNumericType(left.getType());
if (leftNumeric != isNumericType(right.getType())) {
return null;
}
return leftNumeric;
}

private static boolean isNumericType(String type) {
return type != null && NUMERIC_FIELD_TYPES.contains(type.trim()
.toLowerCase(java.util.Locale.ROOT));
}

private static FieldIntent fieldOf(EntityIntent entity, String name) {
if (name == null || entity.getFields() == null) {
return null;
}
for (FieldIntent field : entity.getFields()) {
if (name.equalsIgnoreCase(field.getName())) {
return field;
}
}
return null;
}

/**
* Compiles a {@code requiredWhen} condition into the Java boolean the generated reader tests -
* every comparison rendered against its property's DECLARED type, and ANDed.
Expand Down Expand Up @@ -2333,16 +2398,6 @@ private static String requiredWhenGuard(EntityIntent entity, Map<String, EntityI
return conditions.isEmpty() ? null : String.join(" && ", conditions);
}

/** The entity's field of that name, or {@code null}. */
private static FieldIntent fieldOf(EntityIntent entity, String name) {
for (FieldIntent field : entity.getFields()) {
if (name != null && name.equals(field.getName())) {
return field;
}
}
return null;
}

/** The entity's to-one relation of that name, or {@code null}. */
private static RelationIntent toOneOf(EntityIntent entity, String name) {
for (RelationIntent relation : entity.getRelations()) {
Expand Down Expand Up @@ -3378,6 +3433,12 @@ private static String renderEdmXml(EdmDocument document) {
* The {@code .model} root keys that have their own ELEMENT in the {@code .edm} and so are not also
* written as an attribute of {@code <model>}.
*/
/**
* The field types a {@code checks: compare} entry compares by numeric value rather than as a
* temporal.
*/
private static final Set<String> NUMERIC_FIELD_TYPES = Set.of("integer", "int", "long", "decimal", "double");

private static final Set<String> DOCUMENT_ELEMENT_KEYS = Set.of("entities", "perspectives", "navigations");

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@

/**
* A declarative validation on an {@link EntityIntent} - the cross-field / cross-line rules a plain
* {@code required}/{@code unique} cannot express. Four kinds:
* {@code required}/{@code unique} cannot express. Five kinds:
* <ul>
* <li>{@code exactlyOne} (row-level): exactly one of {@link #fields} is non-null on the record (a
* journal line is either debit or credit) - enforced on every user write;</li>
* <li>{@code compare} (row-level): {@link #field} compared to {@link #than} with {@link #op} - two
* values of the SAME row that must stand in a relation to each other (a due date not before the
* document date, a validity end not before its start) - enforced on every user write;</li>
* <li>{@code requiredWhen}: {@link #field} - the record's own field, or a one-hop
* {@code Relation.field} - must carry a value while {@link #when} holds (an e-mailed invoice needs
* the customer's address). Enforced on every user write, or, with a {@link #status} gate, when the
Expand All @@ -33,16 +36,25 @@ public class CheckIntent {
private String kind;
/** {@code exactlyOne}: the record's own fields, exactly one of which must be non-null. */
private List<String> fields;
/**
* The value the check is ABOUT - the two row-level kinds that name one share the key.
* {@code compare}: the record's own field on the left of the comparison. {@code requiredWhen}: the
* value that must be present - the record's own field, or a one-hop {@code Relation.field} over a
* to-one (whose target may be owned by another model, as everywhere else a path is walked).
*/
private String field;
/**
* {@code compare}: the comparison - {@code ge}, {@code gt}, {@code le}, {@code lt}, {@code eq} or
* {@code ne}. Required: an omitted operator has no defensible default (a due date not BEFORE the
* document date and one strictly AFTER it are different rules).
*/
private String op;
/** {@code compare}: the record's own field on the right of the comparison. */
private String than;
/** {@code itemsSumEqual}: the two numeric item fields whose sums must be equal. */
private List<String> over;
/** {@code itemsMin}: the minimum number of items. */
private Integer count;
/**
* {@code requiredWhen}: the value that must be present - the record's own field, or a one-hop
* {@code Relation.field} over a to-one (the target may be owned by another model, as everywhere
* else a path is walked).
*/
private String field;
/**
* {@code requiredWhen}: the condition under which the value is required - a
* {@code <Property> == <literal>} / {@code != } comparison over the record's own properties, or a
Expand Down Expand Up @@ -169,6 +181,22 @@ public void setKind(String kind) {
this.kind = kind;
}

public String getOp() {
return op;
}

public void setOp(String op) {
this.op = op;
}

public String getThan() {
return than;
}

public void setThan(String than) {
this.than = than;
}

public List<String> getFields() {
return fields;
}
Expand Down
Loading
Loading