From 98689db9e8fd4f6e2061bcb93765566b1c5581a6 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 20 Jul 2026 12:54:38 +0300 Subject: [PATCH 1/3] feat(intent): transitions - guarded on-demand status flips (void/cancel/close) A document whose create-time process has ended (invoice ISSUED, entry POSTED) has no declarative affordance left to change its status: process triggers fire only on create/update/delete, and actions: only opens a custom page. The new top-level transitions: block adds one - a per-record button that moves the record into a designated EntityStatus, guarded server-side: transitions: - name: VoidInvoice forEntity: Invoice # must declare a function: EntityStatus relation from: [3, 4] # allowed source status seed ids setStatus: 8 # target status seed id when: "Paid == 0" # optional ==|!= guard (Calc semantics) label: Void icon: ban Two halves, the generates pattern: TransitionsIntentGenerator (@Order(470)) contributes the per-record button to -custom-action (descriptor carries the endpoint); GlueIntentGenerator.buildTransitions pre-renders the allowed-statuses expression and the Calc-backed when guard into the transitions glue collection, rendered by Transition.java.template into a @Controller at gen/events/Transition/run. The controller re-loads the record, returns 409 with the reason when a guard fails, flips ONLY the status column via the targeted updateProperty (no -updated re-fire), and publishes -transitioned - the same channel workflow setters publish, so postings:/integrations observe a manual void exactly like a workflow transition (the enabler for red-storno reversal postings). ControllerInvoker: a CharSequence return no longer stamps text/plain over a content type the controller set explicitly (the transition controller returns JSON); default unchanged when unset. Verified: - unit: TransitionsIntentTest (7) + GlueTransitionsTest (2) + full engine-intent suite green; ControllerInvokerBindingTest +2 content-type tests, full engine-java suite green - IntentEmissionCoverageIT extended (fixture transition + emission tokens + runtime: cancel 200 with the status flipped, wrong-status 409, when-guard 409 leaving the record untouched) and green Co-Authored-By: Claude Fable 5 --- components/engine/engine-intent/CLAUDE.md | 3 +- .../intent/generator/GlueIntentGenerator.java | 82 +++++++- .../TransitionsIntentGenerator.java | 106 ++++++++++ .../components/intent/model/IntentModel.java | 10 + .../intent/model/TransitionIntent.java | 126 ++++++++++++ .../intent/parser/IntentParser.java | 87 ++++++++ .../main/resources/intent-assistant-guide.md | 40 ++++ .../intent/generator/GlueTransitionsTest.java | 81 ++++++++ .../intent/parser/TransitionsIntentTest.java | 190 ++++++++++++++++++ .../java/controller/ControllerInvoker.java | 7 +- .../ControllerInvokerBindingTest.java | 38 ++++ .../events/Transition.java.template | 63 ++++++ .../template/template.js | 7 + .../template/generateUtils.js | 32 +++ .../tests/api/IntentEmissionCoverageIT.java | 77 ++++++- 15 files changed, 945 insertions(+), 4 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/transition/TransitionsIntentGenerator.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/TransitionIntent.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueTransitionsTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/TransitionsIntentTest.java create mode 100644 components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 801907b7116..7fdc770911f 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -285,6 +285,7 @@ Semantics worth knowing: - **`where` on a user-picked to-one relation = a static dropdown option filter.** `where: { : }` — a single constant condition that permanently narrows the relation's option list to matching target rows (the canonical case: a stock line's Product picker showing only `Type: 1` real products, never services). The `dependsOn` sibling for conditions that do not react to anything. Parser (`validateWhere`): to-one only, never on a composition parent (preset by the layout) or an `EntityStatus`, exactly one pair, scalar literal, same-model property checked immediately (cross-model at generation, like the relation target). Emitted by `EdmIntentGenerator.putOptionsFilter` as two scalar attrs `widgetOptionsFilterBy` (PascalCased) / `widgetOptionsFilterValue` (a YAML integer renders without the Gson `.0` — `stripTrailingZero`); `parameterUtils.js` pre-renders `widgetOptionsFilterValueJs` (numeric stays bare, else quoted+escaped) so the templates emit it verbatim. Harmonia consumption: the **chooser** dropdowns load via `POST /search` with the EQ condition (`form-page` + `document-page` header `loadOptions`), the item dialog keeps a third `filteredOptions` store (`dialogOptionsFor` prefers `draftOptions || filteredOptions || itemOptions`; metadata via `detail-register`'s `#optionsFilterMeta` → `col.filter`), and a combined `dependsOn` + `where` search sends BOTH conditions — while **label-resolution** lookups (list/master/table columns, `itemOptions`) deliberately keep the full set so historical rows referencing now-filtered-out targets still resolve. AngularJS stacks ignore the attrs (Harmonia-only for now — noted in the PR). - **`hierarchy:` on an entity + `leafOnly:` on a to-one relation = tree entities (chart-of-accounts shape).** `hierarchy: Parent` names the entity's own optional to-one self-relation as the tree edge (parser `validateHierarchy`: self-target, non-composition, optional — a required parent leaves no way to author a root). Emitted as the entity-level `hierarchyProperty` (PascalCase FK property) on the `.edm`/`.model`; `CrossModelSupport.TargetInfo` gained a `hierarchyProperty` component so a cross-model referencing module can read it off the owner's `.model`. `leafOnly: true` on a relation targeting a hierarchical entity emits `widgetLeafOnly` + `widgetHierarchyProperty` (the TARGET's edge property) on the FK; parse-checked same-model, generation-checked (loud) for a resolved cross-model target. Enforcement is dual-layer like `where:`: the generated REST controller gains `validateReferences` (leafOnly = child-count via the target's repository — `parameterUtils.leafOnlyRepositoryClass`, a cross-model Java import that resolves because client-Java compiles registry-wide; plus a walk-up cycle guard for the entity's own edge, depth-capped at 100), while the Harmonia pickers mirror it (`hierarchizeOptions`: depth-first order, em-space indentation, leaves only — form page, document header, and the item dialog via `detail-register`'s `#hierMeta` → `col.hier` + the `filteredOptions` store). The manage LIST renders as a Harmonia `x-h-tree` when the entity declares a hierarchy — fixed-depth markup recursion (6 levels; Alpine has no recursive template), search/column-filters fall back to the flat table so matches in collapsed branches stay findable. Plain (non-leafOnly) pickers to hierarchical targets stay flat — indentation rides the leafOnly metadata only (documented v1 scope). - **`order:` on an entity = explicit UI control order.** A list of property names (fields + to-one relations interleaved, matched case-insensitively against the authored names) that sequences the generated form inputs / list columns / detail rows. Default (no `order`) is fields-in-declaration-order then to-one relations, which pushes every relation last — bad UX for a line-item form where `Product`/`UoM` want to sit next to `Name`/`Quantity`. `EdmIntentGenerator.applyOrder` reorders the built `properties` list (which the templates AND the mxGraph diagram both consume) before emitting; a **partial** order is honoured (unlisted properties keep their relative position, appended after the listed ones), and system properties (`ProcessId`/audit columns) are simply left unlisted. The parser (`validateOrders`) checks every listed name resolves to a declared field/relation and rejects duplicates. Worked example: `sample-intent-multi-model` `SalesInvoiceItem` (`order: [Id, SalesInvoice, Product, Name, Quantity, UoM, Price, Discount, Net, Vat, Total]`). +- **`transitions:` (top-level) = guarded on-demand status flip (void / cancel / close / reopen).** The missing affordance for a document whose create-time process has ENDED: process triggers fire only on create/update/delete, and `actions:` only opens a custom page - nothing declarative could transition a finished document again. `TransitionIntent` + parser `validateTransitions` (forEntity must declare a `function: EntityStatus` relation; `from:` = non-empty list of allowed source seed ids; `setStatus:` = target seed id not in `from`; optional `when: " ==|!= "` guard over an own field, resolved case-insensitively - the identifier follows the Calc PascalCase convention). Two halves, the `generates` pattern: `TransitionsIntentGenerator` (`@Order(470)`) contributes the per-record button (`-transition-action.extension`/`.js` on `-custom-action`, descriptor carries `endpoint`); `GlueIntentGenerator.buildTransitions` pre-renders EVERYTHING (the `allowedExpr` over an `int currentStatus` local, the `when` guard as a full `Calc.eval(...).compareTo(...)` expression - null field reads as 0) into the `transitions` glue collection -> `generateUtils.js` case -> `Transition.java.template`: a `@Controller` at `gen/events/Transition/run` that re-loads the record, returns **409** (via `sdk.http.Response.setStatus`) with the reason when a guard fails, flips ONLY the status column via the targeted `updateProperty` (no `-updated` re-fire - no onUpdate reactions), re-loads, and publishes `-transitioned` - the SAME channel the workflow setters and `generates.sourceStatus` publish, so `postings:` glue observes a manual void exactly like a workflow transition. This realizes the "guarded transition" half of the Tier-2 `lifecycle:` sketch below for the post-process case. Covered by `TransitionsIntentTest` + `GlueTransitionsTest` + the `IntentEmissionCoverageIT` transitions assertions. - **`multilingual: true` on an entity + `language:`/`file:` seeds + top-level `languages:` = the multi-language data stack.** A multilingual entity's translatable (string-typed) properties may carry per-language values in a sibling `_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention). `EdmIntentGenerator` emits the EDM `multilingual="true"` entity attribute (the same one the EDM editor writes); the schema template generates the language table from it; the Java DAO template overrides every finder to overlay translations via the SDK `org.eclipse.dirigible.sdk.db.Translator` for the caller's `Accept-Language` (thread-bound `User.getLanguage()`; null → no-op, so listeners/jobs read base values). Translations are authored as **seeds with a `language: bg` code** → `CsvimIntentGenerator` writes them into `
_LANG` (`GUID` auto-numbered, `Language` constant; parser validates the entity is multilingual and row keys are `id` + string/text fields). **Large data sets stay out of the intent**: a seed may reference an authored CSV via `file: data/countries.csv` (exactly one of `file`/`rows`; the path MUST be in a subfolder — root-level `.csv` files are intent-owned and scrubbed) — only the `.csvim` is generated, pointing at the developer-owned file. Top-level `languages: [en, bg]` declares which languages this module PROVIDES translations for (landing on the `.model` root → Harmonia `config.js` `languages`) — it never defines what the stack supports: the **Region & Language** picker always offers the PLATFORM's set (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`, served by `platform-core/services/application-languages.js`), backed by the shared `locale` Alpine store (localStorage `codbex.harmonia.language`) whose value the shared fetch client sends as `Accept-Language` on every call — one flag drives the backend translation, and the document Print flow prefers it too. The application shell compares each app's provided set against the platform set and lists gaps as warnings in Settings; untranslated content falls back to the default language. Caveat (TS parity): editing a record while a non-base language is active saves the displayed (translated) values into the base table — translations are maintained via seeds/DB, not through the generated UI. - **`label:` on an entity = the stored display name.** `label: "{number} - {date|yyyy MMMM} - {Customer.name}"` synthesizes a read-only `Name` VARCHAR(512) property (`labelNameProperty`) recomputed by the generated repository on save/update/`updateWithoutEvent` (`computeName` in `Repository.java.template` - the system path included, because workflow writes stamp label inputs like the document number; `updateProperty`/`recalculate` deliberately skip it). Tokens parse via `LabelExpression` (shared parser/generator): literals + `{field}` + `{Relation.field}` (ONE hop; `|format` = a `DateTimeFormatter` pattern for temporals) - deeper paths are rejected with a compose hint, since labels COMPOSE by referencing the related entity's generated `Name` (`{ProjectTimesheet.Name}`). Emitted as entity `labelExpression` + `labelParts` (List - .model only); parameterUtils sets `p.targetRepositoryClass` on every FK and merges it into relation parts + `hasLabel`. `labelFieldName`/`CrossModelSupport.labelField` prefer `Name`, so every dropdown to a label entity is right automatically. Parser rejects a label next to an authored `name` field and any token referencing a `sensitive` field (the Name is visible on the personal surface). Staleness note: a referenced record's rename propagates on the referencing record's next write - bounded, documented. - **`identity` / `personal` / `sensitive` = the personal (my) surface.** `identity: ` on the entity representing the person (conventionally the unique e-mail) declares how the logged-in username maps to a record; `personal: true` on a record-owning to-one relation (at most one per entity; target must declare identity - same-model parse-checked, cross-model generation-checked via `TargetInfo.identityProperty`) makes the entity get an ADDITIONAL generated `MyController` (rest-java `EntityMyController.java.template`, `personalModels` collection): reads filtered to the mapped identity record (`Criteria.eq(identityProperty, User.getName())`), owner FK forced server-side on writes, foreign/missing records the same 404, `sensitive: true` fields (never the PK/identity/owner FK) stripped from responses AND ignored on writes - the allow-list is server-side, UI hiding alone would be cosmetic security. Composition children inherit the scope through their DIRECT parent (one hop - `requireMyParent` ancestor guard; deeper chains get no personal surface, documented). The power controller is untouched. Emitted as entity `identityProperty` + FK `relationshipPersonal`/`relationshipIdentityProperty` + field `sensitiveProperty`; parameterUtils derives `personalProperty`/`personalParent`/`sensitiveProperties`. Design/status: repo-root `PERSONALIZATION_PLAN.md` (phase A; personal UI, My Shell, per-user task assignee and collection-driven generation are the later phases). @@ -401,7 +402,7 @@ Every action below has a real SDK surface to generate against, so none of this n transitions: - { from: REQUESTED, to: APPROVED, guard: "book.available", do: notify(loanApprovedEmail) } ``` - → guarded-transition glue (+ optionally a small `.bpmn`), reusing resolvers for guards and reactions for `do:`. + → guarded-transition glue (+ optionally a small `.bpmn`), reusing resolvers for guards and reactions for `do:`. (The MANUAL, post-process half of this landed as the top-level `transitions:` capability - see its semantics bullet.) 7. **Document generation (PDF)** — agreements / invoices. ```yaml documents: diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index b72c1a2c988..63e84cbbbf4 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -94,12 +94,14 @@ public void generate(IntentGenerationContext context) { List> expansions = buildExpansions(model, byName, compositionParents, settings); List> settlements = buildSettlements(model, byName, compositionParents, settings, context); List> generates = buildGenerates(model, byName, compositionParents, settings, context); + List> transitions = buildTransitions(model, byName, compositionParents, settings); List> postings = buildPostings(model, byName, compositionParents, settings, context); List> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context); if (triggers.isEmpty() && resolvers.isEmpty() && fieldLoaders.isEmpty() && writers.isEmpty() && setters.isEmpty() && notifications.isEmpty() && schedules.isEmpty() && integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty() - && expansions.isEmpty() && settlements.isEmpty() && generates.isEmpty() && printFeeders.isEmpty() && postings.isEmpty()) { + && expansions.isEmpty() && settlements.isEmpty() && generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() + && postings.isEmpty()) { // No process glue for this intent - any stale .glue is removed by the post-pass scrub. return; } @@ -118,6 +120,7 @@ public void generate(IntentGenerationContext context) { glue.put("expansions", expansions); glue.put("settlements", settlements); glue.put("generates", generates); + glue.put("transitions", transitions); glue.put("postings", postings); glue.put("printFeeders", printFeeders); context.writeModelFile(IntentNaming.baseName(context) + ".glue", JsonHelper.toJson(glue)); @@ -600,6 +603,83 @@ private static List> buildGenerates(IntentModel model, Map> buildTransitions(IntentModel model, Map byName, + Map compositionParents, IntentSettings settings) { + List> out = new ArrayList<>(); + for (org.eclipse.dirigible.components.intent.model.TransitionIntent t : model.getTransitions()) { + if (t.getName() == null || t.getName() + .isBlank() + || t.getForEntity() == null || t.getSetStatus() == null || t.getFrom() == null || t.getFrom() + .isEmpty()) { + continue; // parser already reported the malformed declaration + } + EntityIntent entity = byName.get(t.getForEntity()); + if (entity == null) { + continue; // parser already reported the bad reference + } + if (!settings.shouldGenerate("transitions", t.getName())) { + LOGGER.info("Settings opt-out: keeping existing controller for transition [{}] (not generated)", t.getName()); + continue; + } + String statusProperty = ""; + for (org.eclipse.dirigible.components.intent.model.RelationIntent relation : entity.getRelations()) { + if (relation.isEntityStatus()) { + statusProperty = IntentNaming.pascalCase(relation.getName()); + } + } + if (statusProperty.isEmpty()) { + continue; // parser already reported the missing EntityStatus relation + } + Map e = new LinkedHashMap<>(); + e.put("name", t.getName()); + e.put("className", IntentNaming.pascalIdentifier(t.getName())); + e.put("entity", t.getForEntity()); + e.put("perspective", IntentEntities.resolvePerspective(t.getForEntity(), compositionParents)); + e.put("statusProperty", statusProperty); + e.put("setStatus", String.valueOf(t.getSetStatus())); + List terms = new ArrayList<>(); + List fromIds = new ArrayList<>(); + for (Integer from : t.getFrom()) { + terms.add("currentStatus == " + from); + fromIds.add(String.valueOf(from)); + } + e.put("allowedExpr", String.join(" || ", terms)); + e.put("fromStatuses", String.join(", ", fromIds)); + String guardExpr = ""; + String guardText = ""; + if (t.getWhen() != null && !t.getWhen() + .isBlank()) { + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\s*(\\w+)\\s*(==|!=)\\s*(-?\\d+(?:\\.\\d+)?)\\s*") + .matcher(t.getWhen()); + if (matcher.matches()) { + // Calc reads the field with the calculated-field semantics (null -> 0); compareTo + // keeps the comparison exact for decimals. + guardExpr = "org.eclipse.dirigible.sdk.utils.Calc.eval(\"" + IntentNaming.pascalCase(matcher.group(1)) + + "\", source, 6).compareTo(new java.math.BigDecimal(\"" + matcher.group(3) + "\")) " + + ("==".equals(matcher.group(2)) ? "==" : "!=") + " 0"; + guardText = t.getWhen() + .trim(); + } + } + e.put("guardExpr", guardExpr); + e.put("guardText", guardText); + out.add(e); + } + return out; + } + + /** Test hook: build the {@code transitions} glue collection without a repository. */ + static List> buildTransitionsForTest(IntentModel model) { + return buildTransitions(model, IntentEntities.byName(model), IntentEntities.compositionParents(model), IntentSettings.parse("{}")); + } + /** * Test hook: build the {@code generates} glue collection without a repository. With a null context * a cross-model target falls back to {@link CrossModelSupport}'s naming-convention defaults diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/transition/TransitionsIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/transition/TransitionsIntentGenerator.java new file mode 100644 index 00000000000..757eac1e78b --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/transition/TransitionsIntentGenerator.java @@ -0,0 +1,106 @@ +/* + * 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.transition; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentNaming; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.TransitionIntent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Materializes the client half of each {@code transitions} declaration (the guarded + * on-demand status flip): a contribution to the app's {@code -custom-action} extension + * point - one {@code -transition-action.extension} plus one + * {@code -transition-action.js} (the action descriptor). Like a + * {@link org.eclipse.dirigible.components.intent.model.GeneratesIntent generates} action the + * descriptor carries an {@code endpoint}: the shared {@code customActions} store POSTs the selected + * record's id to it and toasts the result. + * + *

+ * The endpoint is the REST {@code @Controller} generated (server half) from the {@code .glue} + * file's {@code transitions} collection by the {@code template-application-events-java} template, + * served under {@code /services/java//gen/events/Transition/run}. A transition + * is always per-record ({@code type: entity}) - a whole-view status flip has no meaning. + * + *

+ * Idempotent: identical input always produces byte-identical output. The {@code .extension} files + * are intent-owned, so a transition removed from the intent is scrubbed on the next Generate. + */ +@Component +@Order(470) +public class TransitionsIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(TransitionsIntentGenerator.class); + + @Override + public String name() { + return "transitions"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getTransitions() + .isEmpty()) { + return; + } + String project = context.getProjectName(); + for (TransitionIntent t : model.getTransitions()) { + String name = t.getName(); + if (name == null || name.isBlank()) { + LOGGER.warn("Skipping transition with no name"); + continue; + } + String fileBase = name + "-transition-action"; + String modulePath = project + "/" + fileBase + ".js"; + context.writeModelFile(fileBase + ".extension", buildExtensionJson(project, modulePath, t)); + context.writeModelFile(fileBase + ".js", buildDescriptorModule(project, t)); + } + } + + private static String buildExtensionJson(String project, String modulePath, TransitionIntent t) { + Map extension = new LinkedHashMap<>(); + extension.put("module", modulePath); + extension.put("extensionPoint", project + "-custom-action"); + extension.put("description", "Transition [" + t.getName() + "] on [" + t.getForEntity() + "]"); + return JsonHelper.toJson(extension); + } + + private static String buildDescriptorModule(String project, TransitionIntent t) { + Map view = new LinkedHashMap<>(); + view.put("id", project + "-" + t.getForEntity() + "-" + t.getName()); + String label = t.getLabel() == null || t.getLabel() + .isBlank() ? IntentNaming.humanize(t.getName()) : t.getLabel(); + view.put("label", label); + // The server controller (server half) is served under gen/events; NOT the entity api base. + view.put("endpoint", "/services/java/" + project + "/gen/events/" + IntentNaming.pascalIdentifier(t.getName()) + "Transition/run"); + view.put("view", t.getForEntity()); + view.put("type", "entity"); + if (t.getIcon() != null && !t.getIcon() + .isBlank()) { + view.put("icon", t.getIcon()); + } + if (t.getOrder() != null) { + view.put("order", t.getOrder()); + } + // A CommonJS module exporting getView() - the shape the extension-services endpoint loads. + return "const viewData = " + JsonHelper.toJson(view) + ";\n" + "if (typeof exports !== 'undefined') {\n" + + " exports.getView = () => viewData;\n" + "}\n"; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java index 1905b8d5610..5cbe739f763 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -65,11 +65,21 @@ public class IntentModel { private List generates = new ArrayList<>(); /** Declarative postings: source-document status → generated local document + items. */ private List postings = new ArrayList<>(); + /** Declarative on-demand status transitions - guarded per-record buttons (void/cancel/close). */ + private List transitions = new ArrayList<>(); public List getActions() { return actions; } + public List getTransitions() { + return transitions; + } + + public void setTransitions(List transitions) { + this.transitions = transitions == null ? new ArrayList<>() : transitions; + } + public void setActions(List actions) { this.actions = actions == null ? new ArrayList<>() : actions; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/TransitionIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/TransitionIntent.java new file mode 100644 index 00000000000..f75840a13b8 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/TransitionIntent.java @@ -0,0 +1,126 @@ +/* + * 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.model; + +import java.util.List; + +/** + * Declarative on-demand status transition: a per-record button that moves a document into a + * designated {@code EntityStatus} - guarded server-side - after its create-time process has ended. + * The classic consumers are void/cancel an issued document, close a case, reopen a ticket. + * + *

+ * transitions:
+ *   - name: VoidInvoice
+ *     forEntity: Invoice          # must declare a function: EntityStatus relation
+ *     from: [3, 4]                # allowed source status seed ids
+ *     setStatus: 8                # the target status seed id
+ *     when: "Paid == 0"           # optional extra guard over an own numeric field
+ *     label: Void
+ *     icon: ban
+ * 
+ * + * Two halves are generated (the {@code generates} pattern): a client button contributed to the + * app's {@code -custom-action} extension point, and a server {@code @Controller} + * ({@code Transition}) that re-loads the record, validates the guards, flips ONLY the + * status column through the targeted {@code updateProperty} primitive (a workflow-style system + * write - no {@code -updated} re-fire), and publishes the {@code -transitioned} topic so posting + * glue / integrations observe the transition. A guard failure returns 409 and leaves the record + * untouched. + */ +public class TransitionIntent { + + private String name; + /** + * The entity whose generated view shows the button; must declare a {@code function: EntityStatus} + * relation. + */ + private String forEntity; + /** Allowed SOURCE status seed ids - the transition is rejected (409) from any other status. */ + private List from; + /** The TARGET status seed id written when the guards pass. */ + private Integer setStatus; + /** + * Optional extra guard: {@code == } or {@code != } over an own + * field of the entity, evaluated server-side with the SDK {@code Calc} evaluator (a {@code null} + * field reads as 0 - the calculated-field semantics). + */ + private String when; + /** Button label (defaults to a humanized name). */ + private String label; + /** Optional Lucide icon. */ + private String icon; + /** Optional ordering among a view's actions. */ + private Integer order; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getForEntity() { + return forEntity; + } + + public void setForEntity(String forEntity) { + this.forEntity = forEntity; + } + + public List getFrom() { + return from; + } + + public void setFrom(List from) { + this.from = from; + } + + public Integer getSetStatus() { + return setStatus; + } + + public void setSetStatus(Integer setStatus) { + this.setStatus = setStatus; + } + + public String getWhen() { + return when; + } + + public void setWhen(String when) { + this.when = when; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Integer getOrder() { + return order; + } + + public void setOrder(Integer order) { + this.order = order; + } +} 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 7e3ce7d12ec..9620e86ceb7 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 @@ -45,6 +45,7 @@ import org.eclipse.dirigible.components.intent.model.ScheduleIntent; import org.eclipse.dirigible.components.intent.model.SeedIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; +import org.eclipse.dirigible.components.intent.model.TransitionIntent; import org.eclipse.dirigible.components.intent.model.WidgetIntent; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -181,6 +182,7 @@ private static void validate(IntentModel model) { validateForms(model, entityNames, issues); validateActions(model, entityNames, issues); validateGenerates(model, entityNames, usesAliases, issues); + validateTransitions(model, entityNames, issues); validatePostings(model, usesAliases, issues); validateReports(model, entityNames, issues); validateWidgets(model, issues); @@ -2462,6 +2464,91 @@ private static void validateGenerates(IntentModel model, Set entityNames } } + /** The compiled shape of a transition {@code when} guard: {@code ==|!= }. */ + private static final java.util.regex.Pattern TRANSITION_WHEN = + java.util.regex.Pattern.compile("\\s*(\\w+)\\s*(==|!=)\\s*(-?\\d+(?:\\.\\d+)?)\\s*"); + + /** + * A {@code transitions} declaration is a guarded on-demand status flip: it requires the entity to + * declare a {@code function: EntityStatus} relation (the column it writes), a non-empty + * {@code from} list of allowed source seed ids, and a positive {@code setStatus} target outside + * that list. The optional {@code when} guard is a single {@code ==|!= } comparison + * over an own field of the entity (the postings row-guard grammar - evaluated with the Calc + * semantics, where a null field reads as 0). + */ + private static void validateTransitions(IntentModel model, Set entityNames, List issues) { + Map byName = new HashMap<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + byName.put(entity.getName(), entity); + } + } + Set names = new HashSet<>(); + for (TransitionIntent t : model.getTransitions()) { + if (t.getName() == null || t.getName() + .isBlank()) { + issues.add("transition has no name"); + continue; + } + String subject = "transition [" + t.getName() + "]"; + if (!names.add(t.getName())) { + issues.add("duplicate " + subject); + } + EntityIntent entity = null; + if (t.getForEntity() == null || t.getForEntity() + .isBlank()) { + issues.add(subject + " has no forEntity"); + } else if (!entityNames.contains(t.getForEntity())) { + issues.add(subject + " forEntity references unknown entity [" + t.getForEntity() + "]"); + } else { + entity = byName.get(t.getForEntity()); + } + if (entity != null) { + boolean hasStatus = false; + if (entity.getRelations() != null) { + for (RelationIntent relation : entity.getRelations()) { + if (relation.isEntityStatus()) { + hasStatus = true; + } + } + } + if (!hasStatus) { + issues.add(subject + " requires the entity [" + entity.getName() + + "] to declare a function: EntityStatus relation - the transition writes the status"); + } + } + if (t.getFrom() == null || t.getFrom() + .isEmpty()) { + issues.add(subject + " has no from statuses - list the seed ids the transition is allowed from"); + } else { + for (Integer from : t.getFrom()) { + if (from == null || from <= 0) { + issues.add(subject + " from seed ids must be positive"); + break; + } + } + } + if (t.getSetStatus() == null || t.getSetStatus() <= 0) { + issues.add(subject + " has no setStatus - the target status seed id"); + } else if (t.getFrom() != null && t.getFrom() + .contains(t.getSetStatus())) { + issues.add(subject + " setStatus [" + t.getSetStatus() + "] is also in from - a transition must change the status"); + } + if (t.getWhen() != null && !t.getWhen() + .isBlank()) { + java.util.regex.Matcher matcher = TRANSITION_WHEN.matcher(t.getWhen()); + if (!matcher.matches()) { + issues.add(subject + " when [" + t.getWhen() + "] must be ` == ` or ` != `"); + } else if (entity != null && !hasPropertyIgnoreCase(entity, matcher.group(1))) { + // The identifier follows the Calc convention (PascalCase entity property), while + // the field is authored camelCase - resolve case-insensitively. + issues.add(subject + " when references [" + matcher.group(1) + "] which is not a field or to-one relation of [" + + entity.getName() + "]"); + } + } + } + } + /** * Each {@code map} value must name a field or a to-one relation of the source entity; a one-hop * {@code relation.field} path is rejected (not yet supported). Skipped when the source is unknown - 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 1afc7ebdecc..90d7bc34b10 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 @@ -682,6 +682,44 @@ selected record's id to the opened page (as `?id=`). External projects may contr point; the app's own declared actions and third-party contributions render through one path. The opened page dismisses the dialog by posting `{ type: 'harmonia.form.close' }` to its parent. +### transitions - guarded on-demand status flips (void / cancel / close / reopen) + +**Use when:** a document needs a manual status change AFTER its create-time process has ended - void +an issued invoice, cancel a confirmed order, close a case, reopen a ticket. A process `trigger` fires +only on create/update/delete, so a finished document has no declarative affordance left; `transitions` +adds one: a per-record button whose click moves the record into a designated status, guarded +server-side. + +```yaml +transitions: + - name: VoidInvoice + forEntity: Invoice # must declare a function: EntityStatus relation + from: [3, 4] # allowed source status seed ids - 409 from any other status + setStatus: 8 # the target status seed id + when: "Paid == 0" # optional extra guard: == or != + label: Void # button label (defaults to a humanized name) + icon: ban # optional Lucide icon +``` + +**Rules:** unique `name`; `forEntity` must be a declared entity with a `function: EntityStatus` +relation (the column the transition writes); `from` is a non-empty list of positive seed ids; +`setStatus` is a positive seed id not contained in `from` (a transition must change the status). The +optional `when` guard is a single ` ==|!= ` comparison over an own field or to-one +relation of the entity, evaluated server-side with the SDK `Calc` semantics (a `null` field reads as +`0` - so `Paid == 0` also passes on a document that was never paid at all). + +Two halves are generated (the `generates` pattern): a client button (a +`-transition-action.extension` + `.js` contribution to the app's `-custom-action` +point, carrying an `endpoint`; always per-record) and a server-side Java `@Controller` +(`Transition`, via the `.glue` file) served at +`/services/java//gen/events/Transition/run`. The controller re-loads the record, +validates the status + `when` guards (a failure returns **409** with the reason and leaves the record +untouched), then flips ONLY the status column through the targeted `updateProperty` primitive - a +workflow-style system write: no `-updated` re-fire (no onUpdate reactions), but the `-transitioned` +topic IS published, so `postings:` glue and integrations observe the transition exactly as they +observe a workflow status set. Pair it with a posting on the same status to derive follow-up records +(e.g. void -> reversal entry). + ### generates - create one document from another (create-from) **Use when:** a record should spawn a new record of another type - often a document in another model: @@ -1144,6 +1182,7 @@ payment's unallocated balance; entity writes go only through the generated repos | custom `widgets` `kind` | `kpi`, `page` | | rollup `op` | `count` (default), `sum` | | expansion `unit` | `day`, `week`, `month` | +| transition `when` op | `==`, `!=` | ## Mapping requests to capabilities (quick reference) @@ -1151,6 +1190,7 @@ payment's unallocated balance; entity writes go only through the generated repos - "approval / multi-step / workflow" -> **processes** (+ a **form** for each user task) - "a screen to enter / edit X" -> **forms** - "a button on X's view that opens a custom page / action" -> **actions** +- "void / cancel / close / reopen a finished document (a guarded manual status change, per record)" -> **transitions** - "create a Y from an X / generate an invoice from a timesheet / turn a quote into an order" (on a button, per selected record) -> **generates** - "a list / dashboard / count of X by Y" -> **reports** - "who can do what" -> **permissions** diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueTransitionsTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueTransitionsTest.java new file mode 100644 index 00000000000..a81d601658d --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueTransitionsTest.java @@ -0,0 +1,81 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * Verifies the {@code transitions} entries the {@link GlueIntentGenerator} emits: the pre-rendered + * allowed-statuses expression, the resolved EntityStatus property, and the optional Calc-backed + * {@code when} guard. + */ +class GlueTransitionsTest { + + private static final String YAML = """ + name: billing + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: paid, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + transitions: + - name: VoidInvoice + forEntity: Invoice + from: [3, 4] + setStatus: 8 + when: "Paid == 0" + label: Void + icon: ban + """; + + @Test + void rendersTheGuardsAndStatusWrite() { + IntentModel model = IntentParser.parse(YAML); + List> transitions = GlueIntentGenerator.buildTransitionsForTest(model); + assertEquals(1, transitions.size()); + Map t = transitions.get(0); + + assertEquals("VoidInvoice", t.get("name")); + assertEquals("VoidInvoice", t.get("className")); + assertEquals("Invoice", t.get("entity")); + assertEquals("Status", t.get("statusProperty")); + assertEquals("8", t.get("setStatus")); + assertEquals("currentStatus == 3 || currentStatus == 4", t.get("allowedExpr")); + assertEquals("3, 4", t.get("fromStatuses")); + assertEquals("org.eclipse.dirigible.sdk.utils.Calc.eval(\"Paid\", source, 6)" + ".compareTo(new java.math.BigDecimal(\"0\")) == 0", + t.get("guardExpr")); + assertEquals("Paid == 0", t.get("guardText")); + } + + @Test + void noGuardRendersEmptyExpressions() { + IntentModel model = IntentParser.parse(YAML.replace(" when: \"Paid == 0\"\n", "")); + Map t = GlueIntentGenerator.buildTransitionsForTest(model) + .get(0); + // The template's #if($guardExpr != "") renders nothing. + assertEquals("", t.get("guardExpr")); + assertEquals("", t.get("guardText")); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/TransitionsIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/TransitionsIntentTest.java new file mode 100644 index 00000000000..3d6c6d61db8 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/TransitionsIntentTest.java @@ -0,0 +1,190 @@ +/* + * 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.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.TransitionIntent; +import org.junit.jupiter.api.Test; + +/** + * Parse + validation coverage for the {@code transitions} block (the guarded on-demand status + * flip). + */ +class TransitionsIntentTest { + + private static final String VALID = """ + name: billing + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: paid, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + transitions: + - name: VoidInvoice + forEntity: Invoice + from: [3, 4] + setStatus: 8 + when: "Paid == 0" + label: Void + icon: ban + """; + + @Test + void parsesAValidTransition() { + IntentModel model = IntentParser.parse(VALID); + assertEquals(1, model.getTransitions() + .size()); + TransitionIntent t = model.getTransitions() + .get(0); + assertEquals("VoidInvoice", t.getName()); + assertEquals("Invoice", t.getForEntity()); + assertEquals(List.of(3, 4), t.getFrom()); + assertEquals(Integer.valueOf(8), t.getSetStatus()); + assertEquals("Paid == 0", t.getWhen()); + assertEquals("Void", t.getLabel()); + assertEquals("ban", t.getIcon()); + } + + @Test + void rejectsAnEntityWithoutAStatusRelation() { + String yaml = """ + name: billing + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + transitions: + - { name: VoidInvoice, forEntity: Invoice, from: [3], setStatus: 8 } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("requires the entity [Invoice] to declare a function: EntityStatus relation")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsAnUnknownForEntity() { + String yaml = """ + name: billing + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + transitions: + - { name: VoidInvoice, forEntity: Missing, from: [3], setStatus: 8 } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("forEntity references unknown entity [Missing]")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsMissingFromAndSetStatus() { + String yaml = """ + name: billing + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus } + transitions: + - { name: VoidInvoice, forEntity: Invoice } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("has no from statuses")), + "got: " + ex.getIssues()); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("has no setStatus")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsASetStatusInsideFrom() { + String yaml = """ + name: billing + entities: + - name: InvoiceStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus } + transitions: + - { name: VoidInvoice, forEntity: Invoice, from: [3, 8], setStatus: 8 } + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("setStatus [8] is also in from")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsAMalformedOrUnresolvableWhenGuard() { + String malformed = VALID.replace("when: \"Paid == 0\"", "when: \"Paid >= 0\""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(malformed)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("must be ` == ` or ` != `")), + "got: " + ex.getIssues()); + + String unresolvable = VALID.replace("when: \"Paid == 0\"", "when: \"Missing == 0\""); + IntentValidationException ex2 = assertThrows(IntentValidationException.class, () -> IntentParser.parse(unresolvable)); + assertTrue(ex2.getIssues() + .stream() + .anyMatch(i -> i.contains("when references [Missing]")), + "got: " + ex2.getIssues()); + } + + @Test + void rejectsDuplicateNames() { + String yaml = VALID + """ + - name: VoidInvoice + forEntity: Invoice + from: [3] + setStatus: 5 + """; + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("duplicate transition [VoidInvoice]")), + "got: " + ex.getIssues()); + } +} diff --git a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/controller/ControllerInvoker.java b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/controller/ControllerInvoker.java index 6615e29ba6d..092b363be33 100644 --- a/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/controller/ControllerInvoker.java +++ b/components/engine/engine-java/src/main/java/org/eclipse/dirigible/engine/java/controller/ControllerInvoker.java @@ -228,7 +228,12 @@ private void writeResponse(HttpServletResponse response, Method method, Object r } try { if (returnValue instanceof CharSequence cs) { - response.setContentType(MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8"); + // Respect a content type the controller set explicitly (e.g. a JSON string returned + // with sdk.http.Response.setContentType("application/json")); default to text/plain + // only when the method left it unset. + if (response.getContentType() == null) { + response.setContentType(MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8"); + } byte[] bytes = cs.toString() .getBytes(StandardCharsets.UTF_8); response.setContentLength(bytes.length); diff --git a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/ControllerInvokerBindingTest.java b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/ControllerInvokerBindingTest.java index 29ee63041f7..98387c80d82 100644 --- a/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/ControllerInvokerBindingTest.java +++ b/components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/controller/ControllerInvokerBindingTest.java @@ -77,6 +77,44 @@ void path_param_long_is_coerced() { assertEquals("got-42", resp.body()); } + @Test + void string_return_defaults_to_text_plain() { + ControllerEntry entry = consumer.build(loaded(Demo.class)); + Route route = entry.routes() + .stream() + .filter(r -> r.method() + .getName() + .equals("byId")) + .findFirst() + .orElseThrow(); + + FakeResponse resp = new FakeResponse(); + invoker.invoke(new RouteMatch(entry, route, Map.of("id", "42")), mockRequest(null), resp); + + assertEquals("text/plain;charset=UTF-8", resp.getContentType()); + } + + @Test + void string_return_keeps_an_explicitly_set_content_type() { + // A controller returning a JSON string may set the content type itself (via + // sdk.http.Response.setContentType) - the dispatcher must not stamp text/plain over it. + ControllerEntry entry = consumer.build(loaded(Demo.class)); + Route route = entry.routes() + .stream() + .filter(r -> r.method() + .getName() + .equals("byId")) + .findFirst() + .orElseThrow(); + + FakeResponse resp = new FakeResponse(); + resp.setContentType("application/json"); + invoker.invoke(new RouteMatch(entry, route, Map.of("id", "42")), mockRequest(null), resp); + + assertEquals("application/json", resp.getContentType()); + assertEquals("got-42", resp.body()); + } + @Test void query_param_can_be_missing_for_boxed_type() { ControllerEntry entry = consumer.build(loaded(Demo.class)); diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template new file mode 100644 index 00000000000..87cd8d68f44 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Transition.java.template @@ -0,0 +1,63 @@ +package gen.events; + +import org.eclipse.dirigible.sdk.http.Body; +import org.eclipse.dirigible.sdk.http.Controller; +import org.eclipse.dirigible.sdk.http.Post; +import org.eclipse.dirigible.sdk.http.Response; +import org.eclipse.dirigible.sdk.utils.Json; + +/** + * Transition ${name}: the guarded on-demand status flip on a ${entity} (loaded by the id in the + * posted body). The record must currently be in one of the allowed statuses [${fromStatuses}]#if($guardExpr != "") + * and satisfy the guard [${guardText}]#end; otherwise 409 and the record stays untouched. On success + * ONLY the status column is written - through the targeted updateProperty primitive, a + * workflow-style system write (no "-updated" re-fire; a full-row update would merge the stale + * pre-check snapshot and revert concurrent writes) - and the "-transitioned" topic is published so + * posting glue / integrations observe the transition. + * + * Generated from the intent transitions block - do not edit; it is re-generated with the application. + * Entity access goes ONLY through the generated repositories. + * The endpoint is served under /services/java//gen/events/${className}Transition/run. + */ +@Controller +public class ${className}Transition { + + public static class Request { + public Integer id; + } + + @Post("/run") + public String run(@Body Request req) { + // Every branch returns a JSON body - the guard errors carry {"error": reason}. + Response.setContentType("application/json"); + if (req == null || req.id == null) { + Response.setStatus(400); + return "{\"error\": \"missing record id\"}"; + } + gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository repository = + new gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository(); + gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity source = repository.findById(req.id); + if (source == null) { + Response.setStatus(404); + return "{\"error\": \"${entity} not found\"}"; + } + // The status FK read with the Calc semantics (null reads as 0 - never an allowed seed id). + int currentStatus = org.eclipse.dirigible.sdk.utils.Calc.eval("${statusProperty}", source, 0).intValue(); + if (!(${allowedExpr})) { + Response.setStatus(409); + return "{\"error\": \"${name} is allowed only from status [${fromStatuses}] - current status is [" + currentStatus + "]\"}"; + } +#if($guardExpr != "") + if (!(${guardExpr})) { + Response.setStatus(409); + return "{\"error\": \"${name} requires ${guardText}\"}"; + } +#end + repository.updateProperty(req.id, "${statusProperty}", ${setStatus}); + // Reload so the "-transitioned" payload carries the committed row, not the pre-check snapshot. + source = repository.findById(req.id); + org.eclipse.dirigible.sdk.messaging.Producer.sendToTopic( + "${projectName}-${perspective}-${entity}-transitioned", Json.stringify(source)); + return Json.stringify(source); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index eb5c8de57cb..cc2d8df9817 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -128,6 +128,13 @@ export function getTemplate(parameters) { engine: "velocity", collection: "generates" }, + { + location: "/template-application-events-java/events/Transition.java.template", + action: "generate", + rename: "gen/events/{{className}}Transition.java", + engine: "velocity", + collection: "transitions" + }, { location: "/template-application-events-java/events/Posting.java.template", action: "generate", diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 396855629c7..92e3f9fb004 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -897,6 +897,38 @@ export function generateFiles(model, parameters, templateSources) { } } break; + case "transitions": + // On-demand status transitions (intent layer): per declaration, a REST @Controller + // that validates the status/field guards and flips ONLY the status column. All + // guard expressions are pre-rendered by the glue generator - passed through + // untouched; here only the Java package segment is sanitized. The topic keeps the + // RAW perspective (matches the setter publisher). + if (model.transitions) { + for (let i = 0; i < model.transitions.length; i++) { + const t = model.transitions[i]; + const transitionParameters = { + ...parameters, + name: t.name, + className: t.className, + entity: t.entity, + perspective: t.perspective, + javaPerspective: sanitizeJavaIdentifier(t.perspective), + statusProperty: t.statusProperty, + setStatus: t.setStatus, + allowedExpr: t.allowedExpr, + fromStatuses: t.fromStatuses, + guardExpr: t.guardExpr, + guardText: t.guardText + }; + const cleanTransitionParameters = cleanData(transitionParameters); + generatedFiles.push({ + location: location, + content: getGenerationEngine(template).generate(location, content, cleanTransitionParameters), + path: templateEngines.getMustacheEngine().generate(location, template.rename, cleanTransitionParameters) + }); + } + } + break; case "postings": // Declarative postings (intent layer): a MessageHandler on the source's // -transitioned topic creating a local document + computed items. Everything is 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 680e8dcadff..e8376674917 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 @@ -47,7 +47,8 @@ * generated token at minimum, the runtime behavior where reachable), never only the parsed model. * Covered here: {@code immutableWhen} / {@code immutable} (409 on write/delete), {@code checks} * (exactlyOne / itemsMin / itemsSumEqual), {@code hierarchy}/{@code leafOnly}, {@code multilingual} - * (read-time overlay), seed rows carrying a RELATION column, aggregate totals, and the personal + * (read-time overlay), seed rows carrying a RELATION column, aggregate totals, {@code transitions} + * (the guarded on-demand status flip: allowed-status 200, wrong-status/guard 409), and the personal * (my) surface ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, * stripped fields). */ @@ -110,6 +111,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: date, type: date, required: true } - { name: debit, type: decimal, aggregate: true } - { name: credit, type: decimal, aggregate: true } + - { name: paid, type: decimal } - { name: note, type: string, length: 200 } relations: - { name: Account, kind: manyToOne, to: Account, leafOnly: true } @@ -208,6 +210,18 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: confirm, kind: userTask, args: { assignee: personal } } - { name: end, kind: end } + # transitions: the guarded on-demand status flip - Cancel is allowed only on a DRAFT + # entry with nothing paid (Calc semantics: a null field reads as 0, so a never-paid + # entry passes). + transitions: + - name: CancelEntry + forEntity: Entry + from: [1] + setStatus: 3 + when: "Paid == 0" + label: Cancel + icon: ban + seeds: - name: people entity: Person @@ -224,6 +238,7 @@ class IntentEmissionCoverageIT extends IntegrationTest { rows: - { id: 1, name: DRAFT } - { id: 2, name: POSTED } + - { id: 3, name: CANCELLED } - name: units entity: Unit rows: @@ -399,6 +414,19 @@ private void assertEmission() { String ticketPage = contentOf("gen/emission/js/components/pages/Ticket/TicketDocumentPage.js"); assertTrue(ticketPage.contains("sendMessage"), "the chat document page must emit the append-message composer handler"); + // transitions: the server half is a controller that guards the source status + the when + // guard (409) and flips ONLY the status column via the targeted updateProperty; the client + // half is a custom-action contribution carrying the endpoint. + String transition = contentOf("gen/events/CancelEntryTransition.java"); + assertTrue(transition.contains("currentStatus == 1"), "transitions must emit the allowed-statuses guard"); + assertTrue(transition.contains("Calc.eval(\"Paid\", source, 6)"), "the when guard must emit a Calc comparison"); + assertTrue(transition.contains("Response.setStatus(409)"), "a failed guard must surface as 409"); + assertTrue(transition.contains("updateProperty"), "the status flip must be the targeted single-column write"); + assertTrue(transition.contains("-transitioned"), "the flip must publish the -transitioned topic"); + String transitionExtension = contentOf("CancelEntry-transition-action.extension"); + assertTrue(transitionExtension.contains("-custom-action"), + "the transition button must contribute to the app's custom-action extension point"); + // label: the repository recomputes the stored display Name on every write path. String claimRepository = contentOf("gen/emission/data/claim/ClaimRepository.java"); assertTrue(claimRepository.contains("computeName"), "label must emit the display-name computation into the repository"); @@ -523,6 +551,53 @@ private void assertRuntimeEnforcement() { .then() .statusCode(409)); + // transitions: a fresh DRAFT entry cancels (200, status CANCELLED)... + String transitionRun = "/services/java/" + PROJECT + "/gen/events/CancelEntryTransition/run"; + AtomicInteger cancellable = new AtomicInteger(); + restAssuredExecutor.execute(() -> cancellable.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-16\",\"Account\":2}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + cancellable.get() + "}") + .when() + .post(transitionRun) + .then() + .statusCode(200) + .body("Status", equalTo(3))); + // ...a second cancel is rejected from the wrong status (409, record untouched)... + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + cancellable.get() + "}") + .when() + .post(transitionRun) + .then() + .statusCode(409)); + // ...and the when guard rejects a DRAFT entry with something paid, leaving it DRAFT. + AtomicInteger guarded = new AtomicInteger(); + restAssuredExecutor.execute(() -> guarded.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-16\",\"Account\":2,\"Paid\":100}") + .when() + .post(API + "/entry/EntryController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + guarded.get() + "}") + .when() + .post(transitionRun) + .then() + .statusCode(409)); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/entry/EntryController/" + guarded.get()) + .then() + .statusCode(200) + .body("Status", equalTo(1))); + // personal: the my-surface lists ONLY the current user's rows, with the sensitive field // stripped; a foreign record is a 404 (indistinguishable from missing). restAssuredExecutor.execute(() -> given().when() From 9e1db5eb6a866ea9facd15014111e9fca896cf79 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 20 Jul 2026 13:06:56 +0300 Subject: [PATCH 2/3] feat(intent): postings reverses - red-storno reversal on a source void The postings glue could post a document when a source reaches a status, but not UN-post it: a voided/cancelled source left its journal entry standing. The new reverses: mode pairs with the transitions: void primitive: postings: - name: invoicePosting event: { onTransition: Invoice, when: "Status == 3" } creates: JournalEntry backReference: Invoice rule: { ... } items: [ ... ] - name: invoiceStorno event: { onTransition: Invoice, when: "Status == 8" } # the void transition reverses: invoicePosting storno: Storno # the created entity's to-one SELF-relation to the original Semantics (red storno - the accounting correction convention): the reversal inherits creates/backReference/rule/map/items from the reversed sibling and re-derives them from the source with every item amount expression NEGATED on the SAME debit/credit side (never swapped - turnovers stay honest). It locates the ORIGINAL through the empty storno link (back-reference set, link null) and skips fail-soft when none exists (the source was never posted); its creation stamps the link. Both handlers' idempotency guards discriminate by that link: the reversal counts only linked rows, the reversed sibling counts only unlinked ones (stornoProperty/stornoFilterProperty in the glue). The reversal lands as a normal new document - DRAFT status init, number placeholder, checks, the accountant review-and-Post task. Verified: - unit: PostingsReversesIntentTest (5: parse + sibling/inherited-keys/storno validations) + GluePostingsReversesTest (negated exprs, inherited coordinates, storno keys on both entries); full engine-intent suite green - IntentEmissionCoverageIT extended (Doc + PostDoc/VoidDoc transitions + docPosting/docStorno fixture; emission tokens for negation/fail-soft/link; runtime: post -> balanced Entry appears, void -> the reversal appears with negative debit AND credit lines and the storno link to the original) - green Co-Authored-By: Claude Fable 5 --- components/engine/engine-intent/CLAUDE.md | 2 +- .../intent/generator/GlueIntentGenerator.java | 63 +++++++-- .../intent/model/PostingIntent.java | 31 +++++ .../intent/parser/IntentParser.java | 41 ++++++ .../main/resources/intent-assistant-guide.md | 15 +++ .../generator/GluePostingsReversesTest.java | 114 ++++++++++++++++ .../parser/PostingsReversesIntentTest.java | 124 ++++++++++++++++++ .../events/Posting.java.template | 44 ++++++- .../template/generateUtils.js | 2 + .../tests/api/IntentEmissionCoverageIT.java | 115 +++++++++++++++- 10 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 7fdc770911f..978bda54a01 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -279,7 +279,7 @@ Semantics worth knowing: - **`init: ` on a to-one relation = the FK's DB-level default (`RelationIntent.init` → `dataDefaultValue` on the FK property, in both `relationProperty` and `crossModelRelationProperty`).** The relation analogue of a field's `defaultValue`; a new row gets this FK on insert when the column is left unset (e.g. `Status` defaults to the DRAFT seed, `PaymentMethod` to Bank, `SentMethod` to E-mail). **Use `init` for an INITIAL status, never a process step.** A DB default is applied at insert — atomic, no ordering to reason about — while a start-step setter is extra moving parts for the same effect. (Historical note: a start-step `setRelationField` also used to be *clobbered* by the trigger's `ProcessId` write-back, which was a full-row `updateWithoutEvent` merge of a stale snapshot — confirmed live: the invoice reached the Approve task but `Status` stayed null. The trigger now persists `ProcessId` — and a minted business key — via the targeted single-column `repository.updateProperty(...)` (SDK `JavaRepository`/`JavaEntityStore`), so that race is gone; `init:` remains the right modeling for an initial status.) `setRelationField` is for *transitions* (after a user task / on a decision branch). - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events/Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessId` (so it starts at most once). The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events` is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in `service-generate/template/generateUtils.js`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in `generateUtils.js` supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `parameterUtils.js` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). -- **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the KeyFolders catalog's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event `when: " == "`; item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → `generateUtils.js` case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable, not transactional** (the cloud-native consistency model — there is NO cross-step DB rollback; each write commits on its own): the back-reference identifies an existing post, so a redelivery of a COMPLETE post (item count ≥ the derived `expectedItems`) is a no-op, and a redelivery of a HALF-post (an item write failed after the target was saved) clears the partial items and rebuilds the full set — it never throws or half-posts. Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode and the explicit Reverse action — the **compensation** that undoes a genuinely-failed or reversed post — are the deliberate follow-up (needs the void-document event); compensation, not a transaction, is how a bad post is unwound. +- **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the KeyFolders catalog's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event `when: " == "`; item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → `generateUtils.js` case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable, not transactional** (the cloud-native consistency model — there is NO cross-step DB rollback; each write commits on its own): the back-reference identifies an existing post, so a redelivery of a COMPLETE post (item count ≥ the derived `expectedItems`) is a no-op, and a redelivery of a HALF-post (an item write failed after the target was saved) clears the partial items and rebuilds the full set — it never throws or half-posts. Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode LANDED as **`reverses:`** (paired with the `transitions:` void primitive - the "void-document event" is a transition into the void status): a reversal posting inherits creates/backReference/rule/map/items from the reversed sibling, negates every item amount expression on the SAME side (`Calc.eval("-()", ...)` - red storno), locates the original through the empty `storno:` self-link (none -> fail-soft skip), stamps the link on its creation, and both handlers' idempotency guards discriminate by that link (reversal counts linked rows, the sibling counts unlinked ones - `stornoProperty`/`stornoFilterProperty` in the glue). The explicit manual Reverse action (no source void) remains a follow-up. Compensation, not a transaction, is how a bad post is unwound. - **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Caveat discovered while verifying: the generated schema currently emits **no FK constraints at all** (`constraints: []` on every table — same-model included), so this mapping only engages once constraint emission lands; whether to emit them (a suite-wide data-integrity semantics change: deploy order, CSVIM import order, existing tables unaffected by ALTER) is a separate decision, raised with the accounting findings. Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. - **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via the composition child, `statusProperty`, PascalCased fields); `parameterUtils` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (goes straight to `super.update`). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. - **`where` on a user-picked to-one relation = a static dropdown option filter.** `where: { : }` — a single constant condition that permanently narrows the relation's option list to matching target rows (the canonical case: a stock line's Product picker showing only `Type: 1` real products, never services). The `dependsOn` sibling for conditions that do not react to anything. Parser (`validateWhere`): to-one only, never on a composition parent (preset by the layout) or an `EntityStatus`, exactly one pair, scalar literal, same-model property checked immediately (cross-model at generation, like the relation target). Emitted by `EdmIntentGenerator.putOptionsFilter` as two scalar attrs `widgetOptionsFilterBy` (PascalCased) / `widgetOptionsFilterValue` (a YAML integer renders without the Gson `.0` — `stripTrailingZero`); `parameterUtils.js` pre-renders `widgetOptionsFilterValueJs` (numeric stays bare, else quoted+escaped) so the templates emit it verbatim. Harmonia consumption: the **chooser** dropdowns load via `POST /search` with the EQ condition (`form-page` + `document-page` header `loadOptions`), the item dialog keeps a third `filteredOptions` store (`dialogOptionsFor` prefers `draftOptions || filteredOptions || itemOptions`; metadata via `detail-register`'s `#optionsFilterMeta` → `col.filter`), and a combined `dependsOn` + `where` search sends BOTH conditions — while **label-resolution** lookups (list/master/table columns, `itemOptions`) deliberately keep the full set so historical rows referencing now-filtered-out targets still resolve. AngularJS stacks ignore the attrs (Harmonia-only for now — noted in the PR). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 63e84cbbbf4..d832d3ae1af 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -702,17 +702,45 @@ static List> buildGeneratesForTest(IntentModel model) { private static List> buildPostings(IntentModel model, Map byName, Map compositionParents, IntentSettings settings, IntentGenerationContext context) { List> out = new ArrayList<>(); + // A reversal posting's storno link doubles as the discriminator between the reversed + // sibling's own documents (link empty) and reversals (link set) - the SIBLING's handler + // must filter its idempotency lookup by it too, so map: base posting name -> storno. + Map stornoOfReversed = new LinkedHashMap<>(); for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) { + if (posting.getReverses() != null && !posting.getReverses() + .isBlank() + && posting.getStorno() != null) { + stornoOfReversed.put(posting.getReverses(), IntentNaming.pascalCase(posting.getStorno())); + } + } + for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) { + boolean isReverse = posting.getReverses() != null && !posting.getReverses() + .isBlank(); + // Reversal mode: creates/backReference/rule/map/items come from the reversed sibling; + // the reversal contributes its own event plus the storno link, and every item amount + // expression is negated (same sides - red storno). + org.eclipse.dirigible.components.intent.model.PostingIntent effective = posting; + if (isReverse) { + for (org.eclipse.dirigible.components.intent.model.PostingIntent candidate : model.getPostings()) { + if (candidate != posting && posting.getReverses() + .equals(candidate.getName())) { + effective = candidate; + } + } + if (effective == posting || posting.getStorno() == null) { + continue; // parser already reported it + } + } if (posting.getName() == null || posting.getName() .isBlank() - || posting.getEvent() == null || posting.getCreates() == null) { + || posting.getEvent() == null || effective.getCreates() == null) { continue; // parser already reported it } if (!settings.shouldGenerate("postings", posting.getName())) { LOGGER.info("Settings opt-out: keeping existing handler for posting [{}] (not generated)", posting.getName()); continue; } - EntityIntent creates = byName.get(posting.getCreates()); + EntityIntent creates = byName.get(effective.getCreates()); EntityIntent itemsEntity = creates == null ? null : compositionChild(creates, byName); if (creates == null || itemsEntity == null) { continue; // parser already reported it @@ -769,22 +797,27 @@ private static List> buildPostings(IntentModel model, Map usedRuleColumns = new java.util.LinkedHashSet<>(); if (hasRule) { - String ruleEntityName = String.valueOf(posting.getRule() - .get("entity")); + String ruleEntityName = String.valueOf(effective.getRule() + .get("entity")); e.put("ruleEntity", ruleEntityName); // A setting rule entity (the normal case) lives under the global Settings perspective. EntityIntent ruleEntityIntent = byName.get(ruleEntityName); e.put("rulePerspective", ruleEntityIntent != null && ruleEntityIntent.isSetting() ? "Settings" : IntentEntities.resolvePerspective(ruleEntityName, compositionParents)); - Map match = (Map) posting.getRule() - .get("match"); + Map match = (Map) effective.getRule() + .get("match"); Map.Entry selector = match.entrySet() .iterator() .next(); @@ -793,16 +826,16 @@ private static List> buildPostings(IntentModel model, Map> headerAssignments = new ArrayList<>(); - if (posting.getMap() != null) { - for (Map.Entry entry : posting.getMap() - .entrySet()) { + if (effective.getMap() != null) { + for (Map.Entry entry : effective.getMap() + .entrySet()) { headerAssignments.add(postingAssignment(entry.getKey(), entry.getValue())); } } e.put("headerAssignments", headerAssignments); // Item rows: rule(...) refs read the rule row; expressions run through Calc on the source. List> itemRows = new ArrayList<>(); - for (Map row : posting.getItems() == null ? List.>of() : posting.getItems()) { + for (Map row : effective.getItems() == null ? List.>of() : effective.getItems()) { Map rendered = new LinkedHashMap<>(); List> assigns = new ArrayList<>(); String rowGuard = ""; @@ -832,7 +865,9 @@ private static List> buildPostings(IntentModel model, Map> items; + /** + * Reversal (red storno) mode: names a SIBLING posting in this block whose created document this + * posting reverses. The reversal re-derives the sibling's header and items from the source with + * every amount expression NEGATED on the SAME side (never swapped - turnovers stay honest), links + * {@link #storno} to the original, and skips fail-soft when no original exists (the source was + * never posted). {@code creates}/{@code backReference}/{@code rule}/{@code map}/ {@code items} are + * inherited from the sibling and must not be declared here. + */ + private String reverses; + /** + * The created entity's to-one SELF-relation linked to the reversed (original) document - required + * with {@link #reverses}. Doubles as the discriminator between the sibling's own documents (link + * empty) and reversals (link set) for both handlers' idempotency guards. + */ + private String storno; + + public String getReverses() { + return reverses; + } + + public void setReverses(String reverses) { + this.reverses = reverses; + } + + public String getStorno() { + return storno; + } + + public void setStorno(String storno) { + this.storno = storno; + } public String getName() { return name; 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 9620e86ceb7..4524652e5da 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 @@ -2280,6 +2280,47 @@ private static void validatePostings(IntentModel model, Set usesAliases, issues.add(subject + " event requires `when: \" == \"`"); } } + // Reversal mode: creates/backReference/rule/map/items are inherited from the reversed + // sibling; the reversal declares only its own event + the storno self-link. + if (posting.getReverses() != null && !posting.getReverses() + .isBlank()) { + PostingIntent sibling = null; + for (PostingIntent candidate : model.getPostings()) { + if (candidate != posting && posting.getReverses() + .equals(candidate.getName())) { + sibling = candidate; + } + } + if (sibling == null) { + issues.add(subject + " reverses unknown posting [" + posting.getReverses() + "] - it must name a sibling" + + " posting in this block"); + continue; + } + if (posting.getCreates() != null || posting.getBackReference() != null || posting.getRule() != null + || posting.getMap() != null || (posting.getItems() != null && !posting.getItems() + .isEmpty())) { + issues.add(subject + " is a reversal - creates/backReference/rule/map/items are inherited from [" + + posting.getReverses() + "] and must not be declared"); + } + EntityIntent reversed = sibling.getCreates() == null ? null : byName.get(sibling.getCreates()); + if (posting.getStorno() == null || posting.getStorno() + .isBlank()) { + issues.add(subject + " requires `storno: ` - the created entity's link to the reversed document"); + } else if (reversed != null) { + RelationIntent storno = toOneRelationByName(reversed, posting.getStorno()); + if (storno == null || !reversed.getName() + .equals(storno.getTo()) + || storno.isCrossModel()) { + issues.add(subject + " storno [" + posting.getStorno() + "] must be a to-one SELF-relation of [" + + reversed.getName() + "]"); + } + } + continue; + } + if (posting.getStorno() != null && !posting.getStorno() + .isBlank()) { + issues.add(subject + " declares storno without reverses - the storno link belongs to the reversal posting"); + } // creates + items child + backReference EntityIntent creates = posting.getCreates() == null ? null : byName.get(posting.getCreates()); if (creates == null) { 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 90d7bc34b10..35179b4c443 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 @@ -209,6 +209,21 @@ composition is opt-in. A missing rule row or null referenced column SKIPS the posting (the unposted worklist = final-status documents with no back-referencing target), never throws. All writes go through the generated repositories, so numbering/status-init/`checks:` fire on the created document. + **Reversal mode (red storno):** a posting with `reverses: ` undoes the + sibling's document when the source is voided/cancelled - pair it with a `transitions:` void: + ```yaml + - name: invoiceStorno + event: { onTransition: SalesInvoice, model: kf-billing, when: "Status == 8" } # the void status + reverses: salesInvoicePosting # sibling posting in this block + storno: Storno # the created entity's to-one SELF-relation to the original + ``` + `creates`/`backReference`/`rule`/`map`/`items` are inherited from the sibling and must not be + declared. Semantics: locate the ORIGINAL (back-reference = this source, storno link empty) - none + -> skip fail-soft; create the negated copy (every item amount expression negated on the SAME + debit/credit side - never swapped) with the `storno` link stamped; idempotent (rows carrying the + link are the reversal's own; the sibling's guard symmetrically counts only rows without it). The + reversal lands as a normal new document (DRAFT status init, numbering, checks), dated by its own + `map`-inherited header - corrections post into the open period. - `calculatedOnCreate` / `calculatedOnUpdate` - an expression the generated repository assigns to the property on insert / update. Prefer a **neutral arithmetic expression** for numeric totals (`"Quantity * Price"`, `"round(Net * 0.2, 2)"`) - the SDK `Calc` evaluator runs it on the server and diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java new file mode 100644 index 00000000000..e36d3702a2b --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java @@ -0,0 +1,114 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The reversal (red storno) posting glue: the reversal inherits the reversed sibling's + * creates/backReference/rule/map/items, negates every item amount expression on the SAME side, and + * both entries carry the storno coordinates their handlers' idempotency guards discriminate by. + */ +class GluePostingsReversesTest { + + private static final String YAML = """ + name: ledger + uses: + - { model: acme-billing } + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + - name: PostingRule + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: documentType, type: string } + relations: + - { name: ReceivableAccount, kind: manyToOne, to: Account } + - { name: RevenueAccount, kind: manyToOne, to: Account } + - name: JournalEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: entryDate, type: date } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, model: acme-billing } + - { name: Storno, kind: manyToOne, to: JournalEntry } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal, precision: 18, scale: 2 } + - { name: credit, type: decimal, precision: 18, scale: 2 } + relations: + - { name: JournalEntry, kind: manyToOne, to: JournalEntry, composition: true, required: true } + - { name: Account, kind: manyToOne, to: Account, required: true } + postings: + - name: invoicePosting + event: { onTransition: Invoice, model: acme-billing, when: "Status == 3" } + creates: JournalEntry + backReference: Invoice + map: { entryDate: date } + rule: { entity: PostingRule, match: { documentType: "Invoice" } } + items: + - { Account: rule(receivableAccount), debit: "Net + Vat" } + - { Account: rule(revenueAccount), credit: "Net + Vat" } + - name: invoiceStorno + event: { onTransition: Invoice, model: acme-billing, when: "Status == 8" } + reverses: invoicePosting + storno: Storno + """; + + @SuppressWarnings("unchecked") + @Test + void reversalInheritsTheSiblingAndNegatesTheAmounts() { + List> postings = GlueIntentGenerator.buildPostingsForTest(IntentParser.parse(YAML)); + assertEquals(2, postings.size()); + + Map base = postings.get(0); + assertEquals("InvoicePosting", base.get("className")); + // The reversed sibling's guard must exclude reversal rows (they share its back-reference). + assertEquals("", base.get("stornoProperty")); + assertEquals("Storno", base.get("stornoFilterProperty")); + + Map storno = postings.get(1); + assertEquals("InvoiceStorno", storno.get("className")); + assertEquals("8", storno.get("guardValue")); + // Inherited coordinates. + assertEquals("JournalEntry", storno.get("targetEntity")); + assertEquals("JournalEntryItem", storno.get("itemsEntity")); + assertEquals("Invoice", storno.get("backRefProperty")); + assertEquals("\"Invoice\"", storno.get("ruleMatchValueJava")); + assertEquals("Storno", storno.get("stornoProperty")); + assertEquals("", storno.get("stornoFilterProperty")); + + // The header is the sibling's map; the amounts are the sibling's expressions NEGATED on the + // SAME side (red storno - never swapped sides). + List> header = (List>) storno.get("headerAssignments"); + assertTrue(header.stream() + .anyMatch(a -> "EntryDate".equals(a.get("targetProp")) && "source.Date".equals(a.get("expr")))); + List> rows = (List>) storno.get("itemRows"); + assertEquals(2, rows.size()); + List> firstAssigns = (List>) rows.get(0) + .get("assigns"); + assertTrue(firstAssigns.stream() + .anyMatch(a -> "Account".equals(a.get("targetProp")) && "ruleRow.ReceivableAccount".equals(a.get("expr")))); + assertTrue(firstAssigns.stream() + .anyMatch(a -> "Debit".equals(a.get("targetProp")) + && "Calc.eval(\"-(Net + Vat)\", source, 2)".equals(a.get("expr")))); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java new file mode 100644 index 00000000000..2256c736629 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java @@ -0,0 +1,124 @@ +/* + * 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.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.junit.jupiter.api.Test; + +/** Parse + validation coverage for the postings {@code reverses:} (red storno) mode. */ +class PostingsReversesIntentTest { + + private static final String VALID = """ + name: ledger + entities: + - name: Doc + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: DocStatus, function: EntityStatus, init: 1 } + - name: DocStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Entry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + relations: + - { name: Doc, kind: manyToOne, to: Doc } + - { name: Storno, kind: manyToOne, to: Entry } + - name: EntryLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + relations: + - { name: Entry, kind: manyToOne, to: Entry, composition: true, required: true } + postings: + - name: docPosting + event: { onTransition: Doc, when: "Status == 2" } + creates: Entry + backReference: Doc + map: { date: date } + items: + - { debit: "Amount" } + - { credit: "Amount" } + - name: docStorno + event: { onTransition: Doc, when: "Status == 3" } + reverses: docPosting + storno: Storno + """; + + @Test + void parsesAValidReversal() { + IntentModel model = IntentParser.parse(VALID); + assertEquals(2, model.getPostings() + .size()); + assertEquals("docPosting", model.getPostings() + .get(1) + .getReverses()); + assertEquals("Storno", model.getPostings() + .get(1) + .getStorno()); + } + + @Test + void rejectsAnUnknownSibling() { + String yaml = VALID.replace("reverses: docPosting", "reverses: missingPosting"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("reverses unknown posting [missingPosting]")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsInheritedKeysOnAReversal() { + String yaml = VALID.replace("storno: Storno", "storno: Storno\n creates: Entry"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("creates/backReference/rule/map/items are inherited")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsAMissingOrNonSelfStorno() { + String missing = VALID.replace("\n storno: Storno", ""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(missing)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("requires `storno: `")), + "got: " + ex.getIssues()); + + String nonSelf = VALID.replace("storno: Storno", "storno: Doc"); + IntentValidationException ex2 = assertThrows(IntentValidationException.class, () -> IntentParser.parse(nonSelf)); + assertTrue(ex2.getIssues() + .stream() + .anyMatch(i -> i.contains("storno [Doc] must be a to-one SELF-relation of [Entry]")), + "got: " + ex2.getIssues()); + } + + @Test + void rejectsStornoWithoutReverses() { + String yaml = VALID.replace("map: { date: date }", "map: { date: date }\n storno: Storno"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("declares storno without reverses")), + "got: " + ex.getIssues()); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template index 64db534678f..6d1cd8d029a 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template @@ -24,7 +24,12 @@ import org.eclipse.dirigible.sdk.utils.Json; * genuinely failed post is undone by a compensation action, not this handler; * - a missing rule row or a null referenced rule column SKIPS the posting (the document stays on the * unposted worklist - final-status documents with no back-referencing ${targetEntity}); - * - all writes go through the generated repositories (numbering, status init, validations, checks). + * - all writes go through the generated repositories (numbering, status init, validations, checks); + * - REVERSAL mode (intent reverses:): the handler locates the ORIGINAL document (back-reference set, + * storno link empty), skips fail-soft when none exists, and creates the negated copy (same sides, + * negative amounts - red storno) with the storno link stamped; its idempotency guard counts only + * documents CARRYING the storno link, while the reversed sibling's guard counts only documents + * WITHOUT it. */ @Component public class ${className}Posting implements MessageHandler { @@ -88,8 +93,40 @@ public class ${className}Posting implements MessageHandler { expectedItems++; #end #end - java.util.List existingTargets = + java.util.List relatedTargets = targetRepository.findAll(Criteria.create().eq("${backRefProperty}", source.${sourceKeyField})); +#if($stornoProperty != "") + // Reversal (red storno): the ORIGINAL is the reversed posting's document for the SAME + // source - back-reference set, storno link empty. No original -> the source was never + // posted, nothing to reverse (fail-soft skip). Rows carrying the link are THIS handler's + // own creations (the idempotency set). + gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity original = null; + java.util.List existingTargets = + new java.util.ArrayList<>(); + for (gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity candidate : relatedTargets) { + if (candidate.${stornoProperty} == null) { + original = candidate; + } else { + existingTargets.add(candidate); + } + } + if (original == null) { + return; // nothing was posted for this source - nothing to reverse + } +#elseif($stornoFilterProperty != "") + // A sibling reversal posting back-references the same source (with its storno link set) - + // only rows WITHOUT the link are THIS posting's own documents. + java.util.List existingTargets = + new java.util.ArrayList<>(); + for (gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity candidate : relatedTargets) { + if (candidate.${stornoFilterProperty} == null) { + existingTargets.add(candidate); + } + } +#else + java.util.List existingTargets = + relatedTargets; +#end gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity saved; if (!existingTargets.isEmpty()) { saved = existingTargets.get(0); @@ -109,6 +146,9 @@ public class ${className}Posting implements MessageHandler { target.${a.targetProp} = ${a.expr}; #end target.${backRefProperty} = source.${sourceKeyField}; +#if($stornoProperty != "") + target.${stornoProperty} = original.${targetPk}; +#end saved = targetRepository.save(target); } #foreach($row in $itemRows) diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 92e3f9fb004..7e252513fd2 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -957,6 +957,8 @@ export function generateFiles(model, parameters, templateSources) { itemsJavaPerspective: sanitizeJavaIdentifier(po.itemsPerspective), itemsFk: po.itemsFk, backRefProperty: po.backRefProperty, + stornoProperty: po.stornoProperty, + stornoFilterProperty: po.stornoFilterProperty, hasRule: po.hasRule, ruleEntity: po.ruleEntity, ruleJavaPerspective: po.rulePerspective ? sanitizeJavaIdentifier(po.rulePerspective) : "", 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 e8376674917..04edc43f521 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 @@ -48,9 +48,11 @@ * Covered here: {@code immutableWhen} / {@code immutable} (409 on write/delete), {@code checks} * (exactlyOne / itemsMin / itemsSumEqual), {@code hierarchy}/{@code leafOnly}, {@code multilingual} * (read-time overlay), seed rows carrying a RELATION column, aggregate totals, {@code transitions} - * (the guarded on-demand status flip: allowed-status 200, wrong-status/guard 409), and the personal - * (my) surface ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, - * stripped fields). + * (the guarded on-demand status flip: allowed-status 200, wrong-status/guard 409), {@code postings} + * with {@code reverses} (post on a transition; red-storno reversal on void - negated amounts, + * storno link, fail-soft), and the personal (my) surface + * ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, stripped + * fields). */ class IntentEmissionCoverageIT extends IntegrationTest { @@ -116,6 +118,19 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Account, kind: manyToOne, to: Account, leafOnly: true } - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } + # postings back-reference + the reversal's storno self-link (reverses fixture). + - { name: Doc, kind: manyToOne, to: Doc } + - { name: Storno, kind: manyToOne, to: Entry } + + # postings source: PostDoc flips it POSTED (posting fires), VoidDoc flips it + # CANCELLED (the reverses posting fires - red storno). + - name: Doc + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date, required: true } + - { name: amount, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } - name: EntryLine checks: @@ -221,6 +236,35 @@ class IntentEmissionCoverageIT extends IntegrationTest { when: "Paid == 0" label: Cancel icon: ban + - name: PostDoc + forEntity: Doc + from: [1] + setStatus: 2 + label: Post + icon: check + - name: VoidDoc + forEntity: Doc + from: [2] + setStatus: 3 + label: Void + icon: ban + + # postings + reverses (red storno): a POSTED Doc posts one balanced Entry (debit + + # credit); a VOIDED Doc posts the reversal - the SAME lines negated on the SAME sides, + # linked to the original through Entry.Storno, fail-soft when nothing was posted. + postings: + - name: docPosting + event: { onTransition: Doc, when: "Status == 2" } + creates: Entry + backReference: Doc + map: { date: date } + items: + - { debit: "Amount" } + - { credit: "Amount" } + - name: docStorno + event: { onTransition: Doc, when: "Status == 3" } + reverses: docPosting + storno: Storno seeds: - name: people @@ -427,6 +471,17 @@ private void assertEmission() { assertTrue(transitionExtension.contains("-custom-action"), "the transition button must contribute to the app's custom-action extension point"); + // postings reverses: the reversal handler negates the sibling's amount expressions on the + // SAME side, locates the original through the empty storno link (fail-soft skip when none) + // and stamps the link; the sibling's idempotency guard symmetrically excludes linked rows. + String stornoPosting = contentOf("gen/events/DocStornoPosting.java"); + assertTrue(stornoPosting.contains("Calc.eval(\"-(Amount)\", source, 2)"), + "the reversal must negate the sibling's amount expression on the same side"); + assertTrue(stornoPosting.contains("nothing to reverse"), "the reversal must skip fail-soft when the source was never posted"); + assertTrue(stornoPosting.contains("target.Storno = original.Id;"), "the reversal must stamp the storno link to the original"); + String basePosting = contentOf("gen/events/DocPostingPosting.java"); + assertTrue(basePosting.contains("candidate.Storno == null"), "the reversed posting's idempotency guard must exclude reversal rows"); + // label: the repository recomputes the stored display Name on every write path. String claimRepository = contentOf("gen/emission/data/claim/ClaimRepository.java"); assertTrue(claimRepository.contains("computeName"), "label must emit the display-name computation into the repository"); @@ -598,6 +653,60 @@ private void assertRuntimeEnforcement() { .statusCode(200) .body("Status", equalTo(1))); + // postings: posting a Doc creates the balanced Entry (async handler - poll)... + AtomicInteger doc = new AtomicInteger(); + restAssuredExecutor.execute(() -> doc.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-17\",\"Amount\":250}") + .when() + .post(API + "/doc/DocController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + doc.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/PostDocTransition/run") + .then() + .statusCode(200)); + AtomicInteger originalEntry = new AtomicInteger(); + restAssuredExecutor.execute(() -> originalEntry.set(given().when() + .get(API + "/entry/EntryController") + .then() + .statusCode(200) + .body("findAll { it.Doc == " + doc.get() + + " && it.Storno == null }.size()", equalTo(1)) + .extract() + .path("find { it.Doc == " + doc.get() + " && it.Storno == null }.Id")), + 30); + // ...and reverses: voiding the Doc creates the red storno - the SAME lines negated on the + // SAME sides, linked to the original through the storno self-relation. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + doc.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/VoidDocTransition/run") + .then() + .statusCode(200)); + AtomicInteger reversalEntry = new AtomicInteger(); + restAssuredExecutor.execute(() -> reversalEntry.set(given().when() + .get(API + "/entry/EntryController") + .then() + .statusCode(200) + .body("findAll { it.Doc == " + doc.get() + " && it.Storno == " + + originalEntry.get() + " }.size()", equalTo(1)) + .extract() + .path("find { it.Doc == " + doc.get() + " && it.Storno == " + + originalEntry.get() + " }.Id")), + 30); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/entry/EntryLineController") + .then() + .statusCode(200) + .body("findAll { it.Entry == " + reversalEntry.get() + + " && it.Debit != null && it.Debit < 0 }.size()", equalTo(1)) + .body("findAll { it.Entry == " + reversalEntry.get() + + " && it.Credit != null && it.Credit < 0 }.size()", equalTo(1))); + // personal: the my-surface lists ONLY the current user's rows, with the sensitive field // stripped; a foreign record is a 404 (indistinguishable from missing). restAssuredExecutor.execute(() -> given().when() From ce17dcd7d584a6760ff32342b46c64dc031cf642 Mon Sep 17 00:00:00 2001 From: delchev Date: Mon, 20 Jul 2026 13:38:39 +0300 Subject: [PATCH 3/3] fix(intent): report filter translates bare to-one relation names to their FK columns A report filter like `Status != 8` (a to-one RELATION of the source, not a field) passed through buildWhere untranslated - fields rewrote to their qualified physical columns but relation names did not, so the generated query carried a nonexistent column and failed at SQL time. Bare to-one relation names now rewrite to the FK column (Invoice."INVOICE_STATUS"), with guards so join-alias tokens from the dotted-ref pass (Customer."CUSTOMER_NAME") and already-quoted columns are left intact. Found by a suite emission audit: an overdue-invoices report excluding a VOIDED status generated 'AND Status != 8' verbatim into the WHERE. Verified: ReportIntentGeneratorTest +1 (bare relation translated, dotted-ref alias unmangled), full class green. Co-Authored-By: Claude Fable 5 --- .../components/dirigible-java-script.json | 2 +- .../report/ReportIntentGenerator.java | 13 ++++++ .../report/ReportIntentGeneratorTest.java | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json b/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json index de407a13194..f9a8da5dc24 100644 --- a/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json +++ b/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json @@ -11,7 +11,7 @@ "supportLevel": "Stable", "groupId": "org.eclipse.dirigible", "artifactId": "dirigible-components-engine-camel", - "version": "14.0.0-SNAPSHOT", + "version": "15.0.0-SNAPSHOT", "scheme": "dirigible-java-script", "extendsScheme": "", "syntax": "dirigible-java-script:javaScriptPath", diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java index 457ffaf7686..fa6c944cdd2 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -610,6 +610,19 @@ private static String buildWhere(IntentGenerationContext context, IntentModel mo Matcher.quoteReplacement(baseAlias + "." + quote(column(source.getName(), field.getName())))); } } + // A bare to-one RELATION name filters by its FK column (`Status != 8` -> the status FK + // id column) - previously it passed through untranslated and broke the generated SQL. + // The negative lookahead skips join-alias usages (`Customer."CUSTOMER_NAME"` from the + // dotted-ref pass above); the lookbehind skips already-qualified column tokens. + if (source.getRelations() != null) { + for (RelationIntent relation : source.getRelations()) { + if (relation.getName() != null && !relation.getName() + .isBlank()) { + where = where.replaceAll("(?=`/`!=` are untouched. // Normalize only OUTSIDE single-quoted string literals so a value literal that itself contains diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java index aafda3f5a1b..a76f6bb0352 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java @@ -192,6 +192,49 @@ void listWidgetCarriesTheLimitAndLiteralPinsKeepTheirValue() { .get("token")); } + private static final String STATUS_FILTER_INTENT = """ + name: billing + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + - { name: due, type: date } + - { name: balance, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - { name: Customer, kind: manyToOne, to: Customer } + reports: + - name: OverdueInvoices + source: Invoice + dimensions: [number, due, Customer.name] + filter: "due <= CURRENT_DATE AND Customer.name != 'X' AND Status != 8" + """; + + @Test + void filterTranslatesABareToOneRelationToItsFkColumn() { + IntentModel model = IntentParser.parse(STATUS_FILTER_INTENT); + Map document = ReportIntentGenerator.buildForTest(TestContexts.context(model), model.getReports() + .get(0)); + String query = (String) document.get("query"); + // A bare to-one relation name filters by its FK column - previously it passed through + // untranslated (`AND Status != 8`) and broke the generated SQL. + assertTrue(query.contains("Invoice.\"INVOICE_STATUS\" != 8"), query); + // The dotted ref keeps its join-alias form - the bare-relation pass must not mangle the + // alias token it produced. + assertTrue(query.contains("Customer.\"CUSTOMER_NAME\" != 'X'"), query); + assertTrue(!query.contains(" Status "), query); + } + private static final String LEDGER_INTENT = """ name: ledger entities: