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 @@ -40,6 +40,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt

**`permissions.can` is what the generated app enforces ([#6760](https://github.com/eclipse-dirigible/dirigible/issues/6760)):** the authored access model and the enforced one were two disjoint namespaces - `permissions[].role` became `<intent>.roles` while every generated controller's gate was the convention-derived `<project>.<perspective>.<Entity>FullAccess`, a name no intent construct mentions, so granting an authored role granted *nothing* and nothing errored or warned (the roles did show up in the UI, so it looked wired). `PermissionSupport` resolves the `can: [Resource:action]` tokens into per-resource read / write role sets that the EDM and report generators emit as the entity's / report's own `roleRead` / `roleWrite`: a **covered entity is gated entirely by the authored roles** (its convention roles are neither the gate nor declared, or `<intent>.roles` and the template's `default-roles.roles` would each declare the same name), a **composition child inherits the master's** grants when it has none of its own, a covered entity **no grant may write keeps a write gate no declared role satisfies** - what a read-only allow-list says - and an entity no token names is byte-identical to before. The action **vocabulary is closed**: `read`/`view`/`list` → read, `write`/`create`/`update`/`edit`/`delete`/`manage` → write **and** read (a caller who may change a record must be able to load it), `*`/`all` → both; anything else (`approve`, `start`) is a business action with no generated URL and becomes a generation **advisory** naming the token rather than a silent drop, an undeclared resource becomes an **issue**, and a malformed token is refused at **parse**. Since the gate may now name several roles, the rest-java controllers' entity/report gate went from `UserFacade.isInRole` to the any-of `isInAnyRole` the per-property `visibleTo:` machinery already used. The URL half - `<intent>.access` over the controller subtrees, generated pages and report pages the templates publish - is **opt-in through the project's `.settings`** (`{"access": {"generate": true}}`), not a DSL key, and carries method `*` on purpose: the generated controllers read through `POST .../search`, so a "POST means write" split would lock a read-only role out of every list. **A hand-authored `.access` at the project root is scrub-owned** (`.access` is an intent-owned extension) - hand-written constraints belong under `custom/`.

**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.

**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
7 changes: 6 additions & 1 deletion components/engine/engine-intent/CLAUDE.md

Large diffs are not rendered by default.

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

## checks - declarative validations

Row-level `exactlyOne` on every user write; document-level `itemsMin` / `itemsSumEqual` gated on a
status transition (drafting stays unconstrained; the failing transition aborts with the authored
Row-level `exactlyOne` 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
first declared. Flag the lines child explicitly on a document that owns several composition children
Expand All @@ -132,6 +132,28 @@ first declared. Flag the lines child explicitly on a document that owns several
- { kind: exactlyOne, fields: [debit, credit], message: "Exactly one of debit/credit" }
```

`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
owned by another model), and the condition is one or more `<Property> ==|!= <literal>` comparisons
over the record's own properties, ANDed:

```yaml
- name: SalesInvoice
checks:
# holds on every user write
- { kind: requiredWhen, field: reference, when: "kind == 'export'",
message: "An export needs a reference" }
# ...or only at the status the value is finally needed at, enforced by the repository, so the
# transition that sends the document refuses with this message
- { kind: requiredWhen, field: Customer.email, when: "sentMethod == 1", status: SENT,
message: "Sent Method is E-mail but the customer has no e-mail address" }
```

A condition compares a `string`, an `integer`, a `long`, a `boolean` or a to-one's key - the types
an equality is exact on. A malformed condition, or a literal that is not a value of the property's
type, is a validation error rather than a rule that silently never (or always) holds.

## immutableWhen / immutable - user-write immutability

```yaml
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/*
* 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.components.intent.generator;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* The condition of a {@code checks: requiredWhen} entry - the grammar the parser refuses on and the
* Java the generator renders, in one place so the two cannot drift.
*
* <p>
* The condition is a closed set of equality comparisons over the record's own properties, ANDed. It
* is deliberately not an expression language: a condition the generator cannot compile would leave
* the value required unconditionally, i.e. a {@code required} nobody authored, and that failure is
* silent in exactly the way this module refuses everywhere else.
*
* <p>
* The comparison is rendered against the property's DECLARED type rather than generically, because
* a boxed comparison across types is silently always-false: {@code Objects.equals(Long, int)} never
* holds, so a guard on a {@code long} column would switch the rule off and report nothing. That is
* also why only the types with an exact equality are guardable at all - a decimal, a double or a
* date is compared for equality by nobody who means it.
*/
public final class CheckSupport {

/**
* One comparison of a condition: a property of the record against a literal - a number, a quoted
* string, a bare word (a status name is already its seed id here, resolved before the typed
* mapping) or a boolean.
*/
public static final Pattern TERM =
Pattern.compile("\\s*(\\w+)\\s*(==|!=)\\s*('[^']*'|\"[^\"]*\"|-?\\d+|[A-Za-z_][A-Za-z0-9_\\-]*)\\s*");

/** The field types a condition may compare - those with an exact, type-safe equality. */
public static final Set<String> GUARD_TYPES = Set.of("string", "text", "integer", "int", "long", "boolean");

private CheckSupport() {}

/**
* One parsed comparison.
*
* @param property the record's property being compared
* @param equal whether the comparison is {@code ==} (rather than {@code !=})
* @param literal the authored literal, quotes included when it carried them
*/
public record Comparison(String property, boolean equal, String literal) {
}

/**
* The comparisons of a condition - one, or the list form (an implicit AND).
*
* @param when the authored condition
* @return the authored comparison strings, in order
*/
public static List<String> terms(Object when) {
if (when == null) {
return List.of();
}
List<String> terms = new ArrayList<>();
if (when instanceof List<?> list) {
for (Object term : list) {
terms.add(term == null ? "" : String.valueOf(term));
}
} else {
terms.add(String.valueOf(when));
}
return terms;
}

/**
* Parses one comparison.
*
* @param term the authored comparison
* @return the parsed comparison, or {@code null} when it does not have the shape
*/
public static Comparison parse(String term) {
if (term == null) {
return null;
}
Matcher matcher = TERM.matcher(term);
if (!matcher.matches()) {
return null;
}
return new Comparison(matcher.group(1), "==".equals(matcher.group(2)), matcher.group(3));
}

/**
* The Java literal a comparison against a property of this type is rendered with.
*
* @param type the property's declared type ({@code integer}, {@code string}, ...)
* @param literal the authored literal
* @return the Java literal, or {@code null} when the authored literal cannot be one of that type
*/
public static String javaLiteral(String type, String literal) {
if (type == null || literal == null) {
return null;
}
String value = unquote(literal);
return switch (type.toLowerCase(Locale.ROOT)) {
case "string", "text" -> NotificationSupport.quote(value);
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;
default -> null;
};
}

/**
* Renders one comparison as a Java boolean expression.
*
* @param access the Java expression reading the property
* @param equal whether the comparison is {@code ==}
* @param javaLiteral the Java literal from {@link #javaLiteral}
* @return the expression
*/
public static String comparison(String access, boolean equal, String javaLiteral) {
String equals = "java.util.Objects.equals(" + access + ", " + javaLiteral + ")";
return equal ? equals : "!" + equals;
}

/**
* The authored literal without its quotes.
*
* @param literal the authored literal
* @return the value it carries
*/
public static String unquote(String literal) {
if (literal.length() >= 2
&& (literal.startsWith("'") && literal.endsWith("'") || literal.startsWith("\"") && literal.endsWith("\""))) {
return literal.substring(1, literal.length() - 1);
}
return literal;
}
}
Loading
Loading