diff --git a/.claude/docs/intent-dsl-features.md b/.claude/docs/intent-dsl-features.md index 319dd3dc776..b6ae3972d6b 100644 --- a/.claude/docs/intent-dsl-features.md +++ b/.claude/docs/intent-dsl-features.md @@ -56,6 +56,8 @@ Loaded on demand, not on every session: read this before changing a DSL construc **Show it only from a status on (`visibleWhen:`, [#7502](https://github.com/eclipse-dirigible/dirigible/issues/7502)):** composition-child panels rendered unconditionally, so a DRAFT invoice showed empty payment-allocation, copy and reminder panels - `forbidWhen` and `locksWithMaster` hide only the Add/edit/delete affordances and `visibleTo:` gates by role, not status. `visibleWhen: ""` on a field, or on a composition child (written in the master's terms, `Status != DRAFT`), leaves the input or the whole panel out of the generated views until the condition holds against the record the page shows. Same grammar as `requiredWhen` (`==`/`!=`, a list = AND, statuses by seed name); UI-only - nothing is hidden from the API and nothing is deleted when a record regresses. +**A process decision can branch on a to-one relation of its trigger entity ([#7648](https://github.com/eclipse-dirigible/dirigible/issues/7648)):** the field loader inserted before a gateway published the entity's own FIELDS only, so `if: "SendMethod == 1"` - branching on a nomenclature the row carries as a foreign key, which is how "e-mail it or print it" is modelled - got no loader at all. The identifier resolved only when a task form happened to post it as a variable; a completion through the Inbox API carries none, and Flowable then failed the expression on the unknown property and the instance stopped at the gateway. The foreign key is a number on the row like any other column, so all three halves are the ones the field path already walks: the loader looks for to-one relation names too, the gateway's condition is rewritten to the PascalCase the loader publishes, and a seeded NAME on the right-hand side is resolved against the nomenclature THAT relation points at - never the record's own status, which is a different nomenclature carrying a different id for the same word. + **A picker can say a target row is not pickable (`pickable:`, [#7496](https://github.com/eclipse-dirigible/dirigible/issues/7496)):** a to-one's dropdown rendered every target row identically, so a clerk picked a customer with no registration number onto an invoice, typed twenty lines, and learned only at Issue that the invoice could not go out - `dependsOn.filterBy`/`where:` narrow by one value, not by a completeness rule. `pickable: { when: [registrationNumber != null, address != null], else: mark|hide, message: ... }` reads the check guard grammar over the TARGET's own properties, plus `!= null` / `== null` presence tests on any field type; `mark` (the default) lists a failing row disabled with the message as a second line (Harmonia's `aria-disabled` + `data-description`), `hide` leaves it out, and a value the record already holds always keeps its label. Emitted as one JSON attribute `widgetPickable`, evaluated by the shared runtime (`basePage.pickableOptions` / `visibleOptions`) on every picker that builds its own options - manage form, document header and line dialog, my/partner forms and documents. **It never gates the server** (a REST client bypasses any picker): the write-side rule stays a `checks:` entry. Details in the engine-intent guide's pickable bullet. **Two values of one row, related (`checks: compare`, [#7095](https://github.com/eclipse-dirigible/dirigible/issues/7095)):** `checks:` knew `exactlyOne`, `itemsSumEqual` and `itemsMin` - nothing compared two fields of the SAME record, so "a due date is never before the invoice date" was not expressible and a document was saved (200), issued and overdue the moment it existed; the module's workaround was a `calculatedActionOnCreate`/`OnUpdate` class per document type that silently CORRECTED the date instead of refusing it, which is a different thing and never tells the clerk. `- { kind: compare, field: due, op: ge, than: date, message: ... }` is row-level by default, like `exactlyOne`: enforced in every generated controller's `validate()` (the entity, personal and partner surfaces) as a 400 carrying the authored message - a rule about two values of one row holds from the first save, not from a transition (the optional gate that routes it to the transition instead arrived with [#7338](https://github.com/eclipse-dirigible/dirigible/issues/7338), below). `op:` is `ge`/`gt`/`le`/`lt`/`eq`/`ne`, spelled out because an omitted operator has no defensible default. Both operands are the entity's own **fields** - a comparison of two foreign keys means nothing - and must sit in ONE comparison family, which is what the generated code needs: two temporals compare through their own `compareTo` (a `LocalDate` does not compare to an `Instant`), two numbers by value through `BigDecimal` so a `decimal` against a `long` stays exact. Only dates, timestamps and numbers compare; a string / `month` / `week` is refused rather than silently ordered lexicographically, as is a field-with-itself. An **absent operand is not a violation** - a comparison is about two values that exist, and requiredness is its own declaration. diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 055e696ff02..8b2f2df0b4d 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -725,7 +725,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Context = id only.** The trigger calls `Process.start("", key, "{\"Id\": }")` — no entity snapshot — then seeds *locators* via `Process.setVariable`: `__entityUrl` + `__entityId` (the entity's own REST controller + id) and, per to-one relation, `__EntityUrl` + `__EntityLabel`. So the context can never go stale (it holds no entity data). - **The form fetches live.** On open, `template-form-builder-harmonia`'s `form.js` loads the task variables, then GETs `__entityUrl/__entityId` and overlays the live fields — so a read-only field (e.g. a document `Total`) is always current. **This is the literal "form fetches the entity".** It is NOT a path-agnostic-rule violation: the *intent* generators still emit no controller paths — the **events template** (`Trigger.java.template`, which already has `project`/`javaGenFolderName`/`javaPerspective`/`entity`) assembles the URL and hands it to the form as a process variable. The standalone form learns the URL from the context, not from shell knowledge. - **FK → name.** For each to-one relation, `GlueIntentGenerator.buildRelationLinks` emits the logical target names (project / model / perspective / entity + label field — via `CrossModelSupport` for cross-model, the entity's own `name` field for same-model); the generation pipeline (triggers case) builds the controller URL (cross-model uses the target project + sanitized model alias as gen folder, same as the dropdowns); the trigger seeds them; `form.js.resolveRelationNames()` fetches each FK's record and replaces the raw id with its label, **falling back to the id** when a URL/record is missing. - - **Decisions keep the resolver pattern, loading by id.** Since the snapshot is gone, the `Resolver` `JavaDelegate` now loads the **owner (trigger entity) by its id** to read the FK, then loads the target and publishes the field (`ProcessResolverSupport` carries the owner entity/perspective/key). A decision on the trigger entity's **own** field (e.g. `amount > 10000`) is handled symmetrically by a **field loader** (`ProcessFieldLoadSupport` → `fieldLoaders` glue → `FieldLoader.java.template`): a `JavaDelegate` inserted before the gateway that loads the owner by id and publishes just the bare own-field identifiers the condition references (a `relation.field` token is left to the resolver, the form-set `action` is ignored). So every decision input is loaded at the latest moment from the id-only context. + - **Decisions keep the resolver pattern, loading by id.** Since the snapshot is gone, the `Resolver` `JavaDelegate` now loads the **owner (trigger entity) by its id** to read the FK, then loads the target and publishes the field (`ProcessResolverSupport` carries the owner entity/perspective/key). A decision on the trigger entity's **own** field (e.g. `amount > 10000`) is handled symmetrically by a **field loader** (`ProcessFieldLoadSupport` → `fieldLoaders` glue → `FieldLoader.java.template`): a `JavaDelegate` inserted before the gateway that loads the owner by id and publishes just the bare own-field identifiers the condition references (a `relation.field` token is left to the resolver, the form-set `action` is ignored). **The bare identifiers include the entity's TO-ONE RELATIONS (#7648).** The loader looked for fields only, so `if: "SendMethod == 1"` - branching on a nomenclature the row holds as a foreign key, which is how "e-mail it or print it" is modelled - got no loader at all: the identifier resolved only when a task form happened to post it as a variable, and a completion through the Inbox API carries none, so Flowable failed the expression on the unknown property and the instance stopped at the gateway. The FK is a number on the row like any other column, so the field path needed nothing new, only to look: `ownNames` adds the to-one relation names, `BpmnIntentGenerator.ownFieldPascalCase` rewrites them to the PascalCase the loader publishes, and `StatusSymbolResolver` resolves a seeded NAME on the right-hand side against the nomenclature THAT relation points at - never the record's own status, a different nomenclature carrying a different id for the same word. Covered by `ProcessDecisionRelationTest`. So every decision input is loaded at the latest moment from the id-only context. - **No hydrator.** The earlier `HydrateSupport`/`Hydrate.java.template`/`hydrators` glue was removed — the live fetch supersedes it (it captured data too early for documents). - **Read-only render + per-field editable opt-in.** A form referenced by a `userTask` is a **task form**: `FormIntentGenerator` marks every control `readonly` by default and emits `metadata.taskForm=true` (+ the bound entity name + the editable set); the Harmonia form-builder (`template-form-builder-harmonia/ui/index.html.template`) renders a read-only control as a **Label: Value** row (like the detail card) instead of an input. A control whose model is a resolver variable (list `customer.name`) shows the resolved name, not the FK id. Opt a field back to editable with `editable: [Field]` on the form (`FormIntent.editable`); the parser validates each is a displayed field of `forEntity` (relation.field can never be editable — editing it would not write back). Non-task forms (e.g. an inbound create form) keep editable controls. - **An `editable` entry may name a to-one RELATION, and then it renders as a record picker.** The gap it closes: `editable` took plain fields only and `setRelationField` takes a literal seed id baked in at authoring time, so a flow whose fallback is "a person chooses the related record" (pick the driver, pick the account, pick the approver) had to be built OUTSIDE the process as an ordinary entity form plus a guarded `transitions:` button — two user actions where the model describes one. **The Writer needed no change**: an FK column holds the target's integer key, which is the `integer`/`long` coercion it has always emitted, so the only missing server-side piece was that `WriterSupport.editableFields` looked at `fieldOf(owner, name)` and never at the relations. The UI half is the real work — `FormIntentGenerator` emits an `input-select` and `template-form-builder-harmonia` renders it through the Harmonia `x-h-select` contract (v1 fell back to a text input for every unknown widget). **The option list is located by process variables, never by a path**: the control carries the NAMES `__EntityUrl` / `__EntityLabel` — the locators `Trigger.java.template` already seeds for every to-one relation of its trigger entity — plus the target's own key property, and `form.js` reads the URL out of its own model, so the path-agnostic rule holds unchanged. That locator is also what bounds the feature: the picker only works on a task form whose `forEntity` IS the trigger entity, which is the same entity the Writer writes back to, so one parser check covers both. **Two runtime interactions are load-bearing.** (1) `resolveRelationNames()` replaces an FK id in the model with the related record's NAME for the read-only Label: Value cell; a picked relation must be excluded or the select would match no option and the completion would submit a name into an integer column — so `__harmoniaRelationSelects` lists **editable** selects only, and a read-only one keeps going through the name resolution. (2) An `x-h-select` option's `data-value` is an HTML attribute and therefore a string, so the model value is stringified on load (the same rule the generated entity forms follow) — the Writer's `Integer.valueOf(...toString().trim())` takes it from there. The picker offers the first 1000 records and logs when it truncates; it is not a search box. Rejected shapes, each because the picker would be wrong rather than merely unsupported: the `function: EntityStatus` relation (the lifecycle graph owns status moves), the composition parent (re-pointing it re-files the record mid-flow), a cross-model relation (its key belongs to the owner model) and a non-task form (no process context to load from). Covered by `EditableRelationIntentTest`, `TaskFormRelationPickerTest` and `IntentEngineIT.an_editable_relation_renders_a_record_picker_and_writes_the_chosen_foreign_key`. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessFieldLoadSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessFieldLoadSupport.java index 07f985f59c7..921124c6237 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessFieldLoadSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessFieldLoadSupport.java @@ -19,6 +19,7 @@ import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.ProcessIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; @@ -70,7 +71,7 @@ public static List fieldLoads(IntentModel model) { if (owner == null) { continue; // no trigger entity -> no own-field context to load } - Set ownFieldNames = ownFieldNames(owner); + Set ownFieldNames = ownNames(owner); for (StepIntent step : process.getSteps()) { if (!"decision".equals(step.getKind()) || step.getName() == null) { continue; @@ -112,7 +113,21 @@ private static List referencedOwnFields(String condition, Set ow return new ArrayList<>(fields); } - private static Set ownFieldNames(EntityIntent owner) { + /** + * The identifiers of the trigger entity a decision may branch on: its own fields, and - since issue + * #7648 - its TO-ONE relations, whose foreign key the row carries exactly as it carries a field. + * + *

+ * A relation was not loadable before, so {@code if: "SentMethod == 1"} got no loader at all: the + * identifier resolved only if a task form happened to post it as a variable, and a completion + * through the Inbox API carries none - Flowable then failed the expression on the unknown property. + * The FK is a number on the entity like any other column, so loading it needs nothing the field + * path does not already do; what it needed was to be looked for. + * + * @param owner the trigger entity + * @return the authored names a decision may name, fields and to-one relations alike + */ + private static Set ownNames(EntityIntent owner) { Set names = new LinkedHashSet<>(); for (FieldIntent field : owner.getFields()) { if (field.getName() != null && !field.getName() @@ -120,6 +135,15 @@ private static Set ownFieldNames(EntityIntent owner) { names.add(field.getName()); } } + if (owner.getRelations() != null) { + for (RelationIntent relation : owner.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && relation.getName() != null && !relation.getName() + .isBlank()) { + names.add(relation.getName()); + } + } + } return names; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java index fe707a1dcc4..9e5395f597c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java @@ -427,6 +427,16 @@ private static Map ownFieldPascalCase(ProcessIntent process, Map rewrites.put(field.getName(), IntentNaming.pascalCase(field.getName())); } } + // ...and its to-one relations (#7648): the loader publishes their foreign key under the same + // PascalCase name a field gets, so the gateway's condition has to be rewritten to match. + if (entity.getRelations() != null) { + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && relation.getName() != null) { + rewrites.put(relation.getName(), IntentNaming.pascalCase(relation.getName())); + } + } + } } return rewrites; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java index 76ccb846dcd..7a7c390f8bb 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/StatusSymbolResolver.java @@ -49,8 +49,12 @@ final class StatusSymbolResolver { /** A comparison inside a guard / filter expression: term, operator, symbolic right-hand side. */ + // Both identifier runs are POSSESSIVE (`*+`): a shorter one would end inside an identifier, where + // the trailing \b cannot hold, so backtracking into them can never produce a match - it only costs + // a scan per start position, which is quadratic on a long run of identifier characters and is what + // CodeQL flags as a polynomial ReDoS now that an authored decision condition reaches here (#7648). private static final Pattern COMPARISON = - Pattern.compile("(\\b[A-Za-z_][A-Za-z0-9_]*\\b)\\s*(==|!=|<>|<=|>=|=|<|>)\\s*([A-Za-z_][A-Za-z0-9_]*)\\b"); + Pattern.compile("(\\b[A-Za-z_][A-Za-z0-9_]*+\\b)\\s*(==|!=|<>|<=|>=|=|<|>)\\s*([A-Za-z_][A-Za-z0-9_]*+)\\b"); /** * A {@code forbidWhen} / {@code requiredWhen} one-hop comparison @@ -291,6 +295,15 @@ private void rewriteProcesses(Map root) { String eventEntity = waitEventEntityOf(args); rewriteWhen(args, statusRelationName(eventEntity), statusOf(eventEntity), stepSubject + " when"); } + // A decision branching on a to-one relation of the trigger entity (#7648): the FK is + // loaded before the gateway, so the condition compares an id - and a seeded NAME is how + // an author writes it, exactly as at every other status site. The nomenclature is the + // one the NAMED relation points at, not the record's own status: a decision may branch + // on any to-one (a send method, a channel), and resolving against the entity's status + // would take an id out of the wrong nomenclature. + if ("decision".equals(lower(text(step, "kind"))) && args.get("if") instanceof String condition) { + put(args, "if", rewriteRelationComparisons(condition, triggerEntity, stepSubject + " if")); + } // `setRelationField: ` + `value:` writes an id of THAT relation's target - the // status in the canonical case, but the same shape serves any nomenclature FK. String relationName = text(args, "setRelationField"); @@ -650,6 +663,50 @@ private String statusRelationName(String entityName) { return null; } + /** + * Resolve every {@code ==|!= } comparison of a decision condition against the + * nomenclature THAT relation points at (issue #7648). + * + *

+ * A term naming anything but a to-one relation of the entity is left exactly as written - a + * decision compares its own fields, a form-set {@code action} and literals too, and none of those + * is a status site. An ordering comparison against a name is refused for the reason it is + * everywhere: names have no order. + * + * @param condition the authored condition + * @param entityName the process's trigger entity + * @param subject what to name in an issue + * @return the condition with each resolvable name replaced by its seed id + */ + private String rewriteRelationComparisons(String condition, String entityName, String subject) { + if (condition == null || entityName == null) { + return condition; + } + Matcher matcher = COMPARISON.matcher(condition); + StringBuilder rewritten = new StringBuilder(); + while (matcher.find()) { + String replacement = matcher.group(); + Map relation = toOneRelation(entityName, matcher.group(1)); + boolean numeric = INTEGER.matcher(matcher.group(3)) + .matches(); + boolean nullTest = "null".equals(matcher.group(3)); + if (relation != null && !numeric && !nullTest) { + if (!EQUALITY.contains(matcher.group(2))) { + issues.add(subject + " compares [" + matcher.group(1) + "] to the name [" + matcher.group(3) + "] with [" + + matcher.group(2) + "] - a seeded name has no ordering; use ==/!="); + } else { + Integer id = resolveSymbol(matcher.group(3), new Target(text(relation, "to"), text(relation, "model")), subject); + if (id != null) { + replacement = matcher.group(1) + " " + matcher.group(2) + " " + id; + } + } + } + matcher.appendReplacement(rewritten, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(rewritten); + return rewritten.toString(); + } + private Map toOneRelation(String entityName, String relationName) { for (Object node : asList(entities.get(entityName) == null ? null : entities.get(entityName) diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ProcessDecisionRelationTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ProcessDecisionRelationTest.java new file mode 100644 index 00000000000..1170b18fb63 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/ProcessDecisionRelationTest.java @@ -0,0 +1,156 @@ +/* + * 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 static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.eclipse.dirigible.components.intent.generator.bpmn.BpmnIntentGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.StepIntent; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +/** + * A process decision branching on a TO-ONE RELATION of its trigger entity (issue #7648). "Send it + * by e-mail or print it" is a property of the document, held as a foreign key into a small + * nomenclature - but only the entity's own FIELDS were ever loaded before a gateway, so the + * identifier resolved solely when a task form happened to post it as a variable, and a completion + * through the Inbox API carries none: Flowable then failed the expression on the unknown property + * and the instance stopped at the gateway. + * + *

+ * The foreign key is a number on the row like any other column, so all three halves are the ones + * the field path already walks: the loader looks for relation names too, the gateway's condition is + * rewritten to the PascalCase the loader publishes, and a seeded NAME on the right-hand side is + * resolved against the nomenclature THAT relation points at. + */ +class ProcessDecisionRelationTest { + + private static final String YAML = """ + name: dispatch + entities: + - name: SendMethod + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: LetterStatus + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Letter + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: note, type: string, length: 200 } + relations: + - { name: sendMethod, kind: manyToOne, to: SendMethod } + - { name: status, kind: manyToOne, to: LetterStatus, function: EntityStatus, init: 1 } + seeds: + - name: send-methods + entity: SendMethod + rows: + - { id: 1, name: PRINT } + - { id: 2, name: EMAIL } + - name: letter-statuses + entity: LetterStatus + rows: + - { id: 1, name: DRAFT } + - { id: 2, name: SENT } + processes: + - name: Dispatch + trigger: { onCreate: Letter } + steps: + - { name: route, kind: decision, args: { if: "sendMethod == EMAIL", then: mail, else: print } } + - { name: mail, kind: serviceTask, args: { setRelationField: status, value: SENT, next: done } } + - { name: print, kind: serviceTask, args: { setRelationField: status, value: SENT, next: done } } + - { name: done, kind: end } + permissions: + - { role: Clerk, description: Clerk, can: [Letter:read] } + """; + + /** + * The right-hand side is a seeded name of the nomenclature the RELATION points at - never the + * record's own status, which is a different nomenclature carrying a different id for the same word. + */ + @Test + void aSeededNameIsResolvedAgainstTheRelationsOwnNomenclature() { + StepIntent decision = IntentParser.parse(YAML) + .getProcesses() + .get(0) + .getSteps() + .get(0); + + assertEquals("sendMethod == 2", decision.getArgs() + .get("if")); + } + + /** The loader must carry the foreign key, or the gateway has nothing to read. */ + @Test + void theForeignKeyIsLoadedBeforeTheGateway() { + List loads = ProcessFieldLoadSupport.fieldLoads(IntentParser.parse(YAML)); + + assertEquals(1, loads.size(), "one loader, before the one decision: " + loads); + ProcessFieldLoadSupport.FieldLoad load = loads.get(0); + assertEquals("route", load.beforeStep()); + assertEquals("Letter", load.ownerEntity()); + assertEquals(List.of("SendMethod"), load.fields(), "the relation is published under its PascalCase name"); + } + + /** ...and the gateway reads it under exactly the name the loader publishes. */ + @Test + void theGatewayConditionIsRewrittenToTheLoadedName() { + String bpmn = bpmn(); + + assertTrue(bpmn.contains("SendMethod == 2"), "the condition must name the loaded variable:\n" + bpmn); + assertTrue(bpmn.contains("gen.events.dispatch.LoadDispatchRoute"), "the loader must be a step of the flow:\n" + bpmn); + assertTrue(bpmn.contains("sourceRef=\"loadDispatchRoute\" targetRef=\"route\""), + "the loader must run before the gateway:\n" + bpmn); + } + + private static String bpmn() { + IntentModel model = IntentParser.parse(YAML); + IRepository repository = mock(IRepository.class); + IResource missing = mock(IResource.class); + when(repository.getResource(anyString())).thenReturn(missing); + when(missing.exists()).thenReturn(false); + IntentGenerationContext context = new IntentGenerationContext(model, "/proj", "proj", "workspace", "app", repository); + context.setSettings(IntentSettings.scaffold(model)); + + new BpmnIntentGenerator().generate(context); + + ArgumentCaptor paths = ArgumentCaptor.forClass(String.class); + ArgumentCaptor contents = ArgumentCaptor.forClass(byte[].class); + verify(repository, atLeastOnce()).createResource(paths.capture(), contents.capture()); + for (int i = 0; i < paths.getAllValues() + .size(); i++) { + if (paths.getAllValues() + .get(i) + .endsWith("/Dispatch.bpmn")) { + return new String(contents.getAllValues() + .get(i), + StandardCharsets.UTF_8); + } + } + throw new AssertionError("the process BPMN was not written; wrote " + paths.getAllValues()); + } +}