diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 84066c2a4c7..c258d30daaf 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -238,7 +238,7 @@ Six concrete generators currently live in-module: - [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `.edm` (XML) plus `.model` (JSON twin) from the entities + relations declared in the intent. Each entity is fleshed out with EDM editor defaults (icons, menu keys, layout type, perspective metadata, widget types) derived from the entity / field names so the produced model is a complete, openable EDM document. Conventions follow the canonical Dirigible model conventions: **property `name`s are PascalCase** (`id`->`Id`, `loanedOn`->`LoanedOn`, FK `member`->`Member`) via `IntentNaming.pascalCase`, while the physical column **`dataName` stays UPPER_SNAKE** and intent-prefixed (`ORDERS_COUNTRY`) - authoring stays lower camelCase, only the generated model names are PascalCased; every property carries **`auditType="NONE"`**, and a required field/FK also carries **`isRequiredProperty="true"`** (the generated REST controller's required-value validation keys on it, not on `dataNullable`). **Every to-one FK property carries the full relationship metadata the generation reads** (the `.model` has no separate relations array, so it must live on the property): `relationshipType` (`COMPOSITION` for `composition: true`, else `ASSOCIATION`), `relationshipCardinality` (`1_n` composition, `n_1` manyToOne association, `1_1` oneToOne association), `relationshipName` = the FK constraint name `_` (e.g. `Loan_Member` - used as the DB FK constraint name in the schema template), `relationshipEntityName` = target entity, `relationshipEntityPerspectiveName` = target's resolved perspective, `relationshipEntityPerspectiveLabel="Entities"`. **The dropdown's data-service URL (`api//Service.ts`) and the create-detail dialog are built from `relationshipEntityName` + `relationshipEntityPerspectiveName`** - omitting them generated `api/undefined/undefinedController.ts` and a dead dropdown. A `composition: true` relation additionally makes the owner DEPENDENT/MANAGE_DETAILS, inheriting its transitively-resolved parent perspective; every other to-one stays a PRIMARY association DROPDOWN. Dropdown key/value and `referencedProperty` come from the target entity's actual PK and `name`-like fields (PascalCased); the `.model` JSON carries `entities`/`perspectives`/`navigations` (no `relations` key - relations are XML-only, interleaved with their owning ``). The `.edm` also carries an **`mxGraphModel`** diagram with a `style="entity"` vertex per entity (carrying an `` value), a child vertex per property (carrying a `` value), and an edge per FK relation - the EDM editor renders the canvas *exclusively* by decoding `mxGraphModel`, so without it the editor opens empty. Entities are placed in a fixed grid for deterministic output. - [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `.bpmn` per process. Minimal Flowable-flavoured BPMN 2.0 - one start event, one end event, the declared steps, and the sequence flows that connect them. Decisions emit an exclusiveGateway with a conditioned outgoing flow to `args.then` and a default flow to `args.else` (falling back to the next step in the chain when omitted). The `trigger` block keeps the BPMN with a plain none-start event; the runtime auto-start is the `template-application-events-java` listener/handler under `gen/events` (driven off the EDM generator's `triggers` collection), not a BPMN start event. Emits the **`bpmndi:BPMNDiagram`** block (plus the `omgdc`/`omgdi` namespaces): the Flowable/Oryx modeler renders the canvas *only* from the diagram interchange - a process with no `BPMNShape`s opens empty. Nodes are laid out left-to-right along the linear chain at a fixed lane; edges connect source-right to target-left. The layout is deterministic (byte-stable across regenerations); the modeler re-routes on first manual edit. **Naming:** element **ids are uniform lower camelCase** — authored step names already are (`librarianReview`), and the injected decision-resolver task id is the lower-camel form of its handler (`resolveBookPrice`) while the delegate still resolves the PascalCase class `gen.events..ResolveBookPrice`. Task / gateway / process **`name`s are humanized** from the id via `IntentNaming.humanize` (`librarianReview → "Librarian Review"`, `LoanApproval → "Loan Approval"`); the process **id** stays the compact `LoanApproval`. -- [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `
.form` per form. Controls are typed by looking up each declared field against the bound entity (string/uuid -> input-textfield, text -> input-textarea, integer/decimal -> input-number, boolean -> input-checkbox, date -> input-date, timestamp -> input-datetime-local). A plain field's control `model` binds to the **PascalCase** entity property (`loanedOn` -> `LoanedOn`) to match the EDM. A **`relation.field` form field** (`book.price` on a form bound to `Loan`) is a one-hop to-one relation of the bound entity; its control binds `model` to the **`_` process variable** (`book_price`), is typed from the **target** entity's field (so `book.price` is an input-number), is `readonly`, and is labelled by the humanized path ("Book Price"). This is the form counterpart of the decision resolver: a BPM task form's model is the process variables, which hold the `Book` FK id but not the book's own fields, so a resolver step (see [`ProcessResolverSupport`](#)) must load it - **the form does not fetch the related entity itself** (it is a standalone iframe with only the process variables). A `relation.field` field on a form *not* used by a user task generates no resolver and stays empty (documented limitation; the resolver only exists inside a process). The relation **may be cross-model** (#7093): `Customer.email` where `Customer` is owned by another model is resolved at GENERATION against that owner's `.model` (the same `CrossModelSupport` read a `notify` recipient and a `languageFrom` use), and the control renders as a read-only text field - the target's LOGICAL type is a fact of the owner model, not of this document. Actions become buttons in a trailing `container-hbox`; the button colour is inferred from the action name (approve -> positive, reject/decline/delete/cancel -> negative, save/submit -> emphasized). A stub controller code block declares `onClicked` handlers as TODOs - wiring to a backend is left to the downstream template engine or a hand-authored override under `custom/`. +- [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `.form` per form. Controls are typed by looking up each declared field against the bound entity (string/uuid -> input-textfield, text -> input-textarea, integer/decimal -> input-number, boolean -> input-checkbox, date -> input-date, timestamp -> input-datetime-local). A plain field's control `model` binds to the **PascalCase** entity property (`loanedOn` -> `LoanedOn`) to match the EDM. A **`relation.field` form field** (`book.price` on a form bound to `Loan`) is a one-hop to-one relation of the bound entity; its control binds `model` to the **`_` process variable** (`book_price`), is typed from the **target** entity's field (so `book.price` is an input-number), is `readonly`, and is labelled by the humanized path ("Book Price"). This is the form counterpart of the decision resolver: a BPM task form's model is the process variables, which hold the `Book` FK id but not the book's own fields, so a resolver step (see [`ProcessResolverSupport`](#)) must load it - **the form does not fetch the related entity itself** (it is a standalone iframe with only the process variables). A `relation.field` field on a form *not* used by a user task generates no resolver and stays empty (documented limitation; the resolver only exists inside a process). The relation **may be cross-model** (#7093): `Customer.email` where `Customer` is owned by another model. **This generator reads no owner model** - `entitiesByName` holds the LOCAL entities only, so a cross-model target resolves to nothing and the control falls to `pickControl(null)`, a read-only text field; the target's LOGICAL type is a fact of the owner model, not of this document, and typing the control from it would mean reading another model's `.model` here for a cosmetic difference. The owner read happens once, in the Glue resolver (`ProcessResolverSupport` + `CrossModelSupport`, described below), which is also where an unknown owner field is refused. Actions become buttons in a trailing `container-hbox`; the button colour is inferred from the action name (approve -> positive, reject/decline/delete/cancel -> negative, save/submit -> emphasized). A stub controller code block declares `onClicked` handlers as TODOs - wiring to a backend is left to the downstream template engine or a hand-authored override under `custom/`. - [`ReportIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java) writes one `.report` per report in the **Dirigible `.report` shape**: `name` / `alias` (the source entity, the base-table alias) / `table` (intent-prefixed physical table via `IntentNaming.tableName`) / `columns` / a fully-materialised SQL **`query`** / `conditions` / `security`. The report is rooted at `source`; each dimension/measure resolves to a physical column - a plain field (`dueOn`) -> a source column; a **`relation.field` path (`member.name`) -> a `JOIN` to the related entity** plus a column on it (this is how a report shows a parent's columns); a **bare to-one relation (`member`) -> a `JOIN` showing the target's label (`name`-like) field, not the raw FK id** (so "group by member" displays the member's name; use `member.id` for the id). **The join type follows the relation (`joinType(RelationIntent)`): `INNER` for a `required: true` relation or a composition parent (the FK is NOT NULL - the EDM generator's own rule), `LEFT` for an optional one** - an INNER join on an optional relation dropped every row without it from the report and from its `count` widget (dirigible [#7105](https://github.com/eclipse-dirigible/dirigible/issues/7105): two store-less purchase orders, "Open Purchase Orders" = 0). The same rule drives the filter and parameter hops, and a parameter over an optional hop coalesces its column like any nullable one; a measure `count(*)`/`sum(total)`/`avg`/`min`/`max` -> an aggregate column (dimensions then become the `GROUP BY`). `filter` becomes the `WHERE`, with intent field names rewritten to qualified physical columns (`dueOn <= CURRENT_DATE` -> `Loan."LOAN_DUE_ON" <= CURRENT_DATE`); operators / literals / `CURRENT_DATE` pass through. `security` is `{generateDefaultRoles, roleRead: .Report.ReadOnly}`. Column physical names + the base table mirror `EdmIntentGenerator` so the report never drifts from the model. (The earlier version left `query` empty and used a non-standard shape - reports did not run.) **All physical table and column identifiers in the `query` are double-quoted** (`"LIBRARY_LOAN"`, `Loan."LOAN_DUE_ON"`); PostgreSQL folds *unquoted* identifiers to lower case and would never match the quoted UPPER_SNAKE objects the platform creates, so an unquoted query runs on H2 but fails on Postgres. Table **aliases** stay unquoted (they fold consistently on both sides). The `quote(...)` helper + the JSON-escaping of those quotes inside the `.report` `query` string (assert `\"` in tests) are the two gotchas. **Caveat:** the base-table alias is the entity name; a reserved-word entity name (`Order`) yields an unquoted reserved alias - keep entity names non-reserved, as the standard Dirigible apps do. - **The `.report` carries the query twice - as the structured builder model AND as the materialised `query` - and the two must agree (dirigible [#6675](https://github.com/eclipse-dirigible/dirigible/issues/6675)).** The Report Editor rebuilds the query from the structured model on open and, when the rebuild matches, lets its visual builder own the query from then on; a **mismatch falls back to free-style**, where the query string is the source of truth and the builder panels are hidden. So a report whose model said less than its query used to open dirty and get rewritten destructively on save (quoting lost, `COUNT(*)` -> `COUNT(alias.*)`, the joins deleted, a bare empty `WHERE`, a date bucket degraded to its raw column). Two halves fixed it: the editor's round-trip guard (PR [#6677](https://github.com/eclipse-dirigible/dirigible/pull/6677)) and this generator emitting a model that reproduces its own query. The rules here: **`query` is built FROM the emitted `columns` / `joins` / `conditions`** (`buildQuery` + `columnTerm`), so the two cannot drift; a computed dimension (date bucket, ageing `CASE`, balance window) carries its SQL as **`columns[].expression`**, which the builder emits verbatim; a bare `count(*)` is the column **`name: "*"`** so the builder emits `COUNT(*)` and not the H2-rejected `COUNT(alias.*)`; the resolved joins are emitted as **`joins: [{alias, name, type: INNER, condition}]`** (the editor's own field names) instead of living only inside the query string; and `conditions` is emitted **only when the whole predicate round-trips** - every `AND`-separated term a plain comparison, no `OR` and no term whose quotes are unbalanced (which would mean the split cut a string literal). A filter that does not decompose keeps its parentheses in the query, emits no `conditions`, and the report deliberately opens free-style - the safe half of the guard. `ReportEditorRoundTripTest` holds the contract: it is a Java port of the editor's `buildQuery()` run against the emitted document, so **keep the two in step whenever either side changes**. - [`PermissionIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java) writes `.roles` from the intent's `permissions` block (deduped by role name), and - opt-in - `.access`. @@ -273,8 +273,9 @@ All implementations are Spring `@Component` beans implementing `IntentTargetGene 1. `POST /services/ide/intent/parse` (body: raw YAML) - `IntentParser.parse` → `IntentModel` JSON, or `422 {"issues": [...]}` with every structural problem at once. The editor calls this on a debounce to refresh the diagram and the validation strip; nothing is persisted. 2. `POST /services/ide/intent/generate?workspace=&project=&path=` - resolves the current user's workspace project via `WorkspaceService` (so it is inherently user-scoped), reads the intent file, runs every registered `IntentTargetGenerator` (failures per generator are logged and isolated), then scrubs stale intent-owned files. Returns `{"written": [...], "scrubbed": [...]}`. 3. **Stale-output scrub.** Model-layer files at the project root that the pass did not re-emit are deleted. The extension filter keeps the scrub away from the `.intent` file itself, code files, and subfolders (`gen/`, `custom/` - only direct child resources are considered). Removing a process / form / seed from the intent removes its model file on the next Generate. -4. **Generation is idempotent and diff-stable** - identical input produces byte-identical output, and byte-identical content is not rewritten. -5. **Consumed-attributes audit (#6543).** After the model-to-code recipes run, every `.model` this pass wrote is audited against the template that just consumed it: `ModelGenerationService.auditConsumedAttributes(...)` → `ConsumedAttributesAudit`. An attribute the model *sets* (a null, blank, `false` or zero value asks for nothing and is skipped) is **claimed** when its name appears as a token in one of the sources the template descriptor lists, or when it is in one of the two declared sets — `PIPELINE_CLAIMED` (read by the Java stages in `...ide.template.service.model`, which derive something else from it, so no template ever names it) or `EDITOR_OWNED` (the entity editor's own authoring attributes, which generation is not expected to read). Anything else is reported as an **advisory**, one line per attribute name (first occurrence + count). This closes the silent-degradation class: an authored attribute nobody reads ships with every step green — the intent parses, the `.model` carries it, generation succeeds, and the promised behaviour is simply absent. The template half is scanned per generation, against the template **actually published in this registry**, which is what makes a *stale* template visible; producer/consumer drift in the attribute name (`numberStampOn` emitted, `numberStampOnCreate` read — the first thing this audit found, now fixed in `EdmIntentGenerator`) is the other shape. It is an advisory and not an issue because the fix is almost always in the template or the generator, so the assistant's repair loop must not spend a round rewriting the document over it. The template sources being unreadable reports **nothing** — a template nobody can load is not evidence that anything is unconsumed — and only `.model` entries are audited (glue / form / report model files have an entirely different shape). +4. **A refused pass writes NOTHING (#7227).** An `IntentValidationException` out of any generator is the developer's authoring error, so it is rethrown to the caller as a 422 - and before it leaves, `IntentGenerationContext.rollbackWrittenFiles()` undoes every change this pass made at the project root (a file it created is removed, one it overwrote gets its previous content back; the scaffolded `.settings` included, since that too goes through `writeModelFile`). The generators run in `@Order`, so without that a check placed in a late generator left the earlier ones' output in the workspace, past the scrub, which the loop never reaches: the reported case is the cross-model `relation.field` refusal at `@Order(350)` whose whole justification is that skipping the resolver would leave a `.bpmn` with a `Resolve<...>` service task nothing generated a handler for - which is exactly what the 422 itself left behind, `BpmnIntentGenerator` (300) emitting that task from the lookup-free convention regardless. Refusing at generation must cost the developer no more than refusing at parse. A restore that itself fails is logged and the rest still run - the caller is on its way to reporting the authoring error, and one unrestorable file must not hide it. The write journal records only what actually CHANGED (a byte-identical write has nothing to undo), so a rollback is as diff-stable as the pass it undoes. Note what is deliberately NOT rolled back: the model-to-code recipes, which run after the generators and report their own per-entry outcome - by then the model files are on disk and a partial result is the caller's to see. +5. **Generation is idempotent and diff-stable** - identical input produces byte-identical output, and byte-identical content is not rewritten. +6. **Consumed-attributes audit (#6543).** After the model-to-code recipes run, every `.model` this pass wrote is audited against the template that just consumed it: `ModelGenerationService.auditConsumedAttributes(...)` → `ConsumedAttributesAudit`. An attribute the model *sets* (a null, blank, `false` or zero value asks for nothing and is skipped) is **claimed** when its name appears as a token in one of the sources the template descriptor lists, or when it is in one of the two declared sets — `PIPELINE_CLAIMED` (read by the Java stages in `...ide.template.service.model`, which derive something else from it, so no template ever names it) or `EDITOR_OWNED` (the entity editor's own authoring attributes, which generation is not expected to read). Anything else is reported as an **advisory**, one line per attribute name (first occurrence + count). This closes the silent-degradation class: an authored attribute nobody reads ships with every step green — the intent parses, the `.model` carries it, generation succeeds, and the promised behaviour is simply absent. The template half is scanned per generation, against the template **actually published in this registry**, which is what makes a *stale* template visible; producer/consumer drift in the attribute name (`numberStampOn` emitted, `numberStampOnCreate` read — the first thing this audit found, now fixed in `EdmIntentGenerator`) is the other shape. It is an advisory and not an issue because the fix is almost always in the template or the generator, so the assistant's repair loop must not spend a round rewriting the document over it. The template sources being unreadable reports **nothing** — a template nobody can load is not evidence that anything is unconsumed — and only `.model` entries are audited (glue / form / report model files have an entirely different shape). ## AI assistant (Claude chat + patch preview) @@ -740,7 +741,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` / - **Process glue externalized to `.glue`** (the precedent: `.report`/`.form` were lifted out of the EDM). The `triggers` + `resolvers` collections live in `.glue` (`GlueIntentGenerator`), NOT the `.model` - the EDM describes entities, the BPMN describes flow, neither owns "who starts a process / how its context is populated". The Glue-Code template binds to `extension: "glue"`; the generation pipeline has `triggers` + `resolvers` collection cases. (Supersedes the older "triggers in the .model" wiring.) - **`setField` service tasks + `next` routing (declarative status/field set):** a `serviceTask` with `setField`/`value` sets a string/text field of the trigger entity via a generated `gen/events//.java` `JavaDelegate` (`SetFieldSupport` → the `setters` glue collection + `SetField.java.template` + the `setters` case in the generation pipeline), persisting with the targeted single-column `updateProperty`; a `next: ` arg overrides a step's linear successor so a decision's two branches converge instead of falling through. Replaces the `custom.` scaffold for the approve→ACTIVE / reject→REJECTED pattern. See the "Decision steps" / `setField` semantics bullet above. Covered by `IntentEngineIT.set_field_glue_sets_entity_status_on_approve_reject_branches`. - **`setRelationField` (set a relation-FK status):** the generic, relation-valued sibling of `setField` — `args: { setRelationField: , value: }` sets a to-one relation's FK to a seed id (unquoted), via the same `setters` glue + `SetField.java.template` (a `relation` flag branches the template). Allowed on a serviceTask (bound directly) and on a userTask (setter inserted after the task, like the Writer). See the `setRelationField` semantics bullet above. -- **Resolvers (`relation.field`) — for decisions AND user-task forms:** a `relation.field` referencing a one-hop to-one relation of the trigger entity, in either a **decision condition** (`book.price > 500`) **or a user-task form's `fields`** (`book.price` on `ApproveLoan`), gets a `${JavaTask}` resolver service task and a `gen/events//Resolve.java` `JavaDelegate` (generated from `.glue`) that loads the related entity by FK id and sets the `_` process variable (`book_price`); decision conditions are rewritten to the resolved variable and form controls bind to it. **The resolver is inserted before the EARLIEST step that needs it** (`ProcessResolverSupport.resolvers` scans steps in declaration order, deduping per process+handler so the first occurrence wins). This is the crux of the form fix: `book.price` used by both `librarianReview`'s form (step 1) and the later `rareBook` decision used to anchor the resolver at the *decision*, so the *form* (which runs first) showed the field empty; anchoring at the earliest step (the form's user task) fills the form, and the variable persists so the downstream decision still resolves. Decision conditions are rewritten against **all** process resolvers (variables are process-global), but each resolver task is inserted **once**. `ProcessResolverSupport` + `IntentEntities` (shared perspective/PK resolution). Insertion/rewrite happen on a copy of the step list so the glue generator still sees the original paths. A form-only `relation.field` (no decision references it) is a resolver trigger in its own right; one used on a form *outside* any process stays empty (no process → no resolver). The parser validates every form `relation.field` is a one-hop to-one of the form's `forEntity` with the field present on the target (multi-hop rejected) - **except a CROSS-MODEL to-one, whose target fields are unknown at parse time** (#7093): it is resolved at generation like every other cross-model reference, through `ProcessResolverSupport.CrossModelLookup` (`GlueIntentGenerator.resolverCrossModelLookup` does the IO, so the path logic stays unit-testable). The resolved facts are the owner's perspective (its gen data subfolder) and the key type behind the `Number` accessor, and the generated delegate imports the OWNER's `gen..data.` Entity/Repository - `bindResolver` picks that gen folder from the descriptor's `crossModel`/`targetModel`, DEFAULTED to the local one so a `.glue` written before the key existed still renders. A field the owner model does not declare **fails loudly (422)** rather than being skipped: a skipped resolver leaves the BPMN with a service task pointing at a handler nothing generated and the control bound to a variable nothing ever sets. Two things keep the three consumers of `resolvers()` in step - membership does not depend on the lookup (a cross-model path always yields a resolver, or the whole pass throws), and the BPMN generator deliberately calls the lookup-free overload, since it only needs the process/step/handler names. +- **Resolvers (`relation.field`) — for decisions AND user-task forms:** a `relation.field` referencing a one-hop to-one relation of the trigger entity, in either a **decision condition** (`book.price > 500`) **or a user-task form's `fields`** (`book.price` on `ApproveLoan`), gets a `${JavaTask}` resolver service task and a `gen/events//Resolve.java` `JavaDelegate` (generated from `.glue`) that loads the related entity by FK id and sets the `_` process variable (`book_price`); decision conditions are rewritten to the resolved variable and form controls bind to it. **The resolver is inserted before the EARLIEST step that needs it** (`ProcessResolverSupport.resolvers` scans steps in declaration order, deduping per process+handler so the first occurrence wins). This is the crux of the form fix: `book.price` used by both `librarianReview`'s form (step 1) and the later `rareBook` decision used to anchor the resolver at the *decision*, so the *form* (which runs first) showed the field empty; anchoring at the earliest step (the form's user task) fills the form, and the variable persists so the downstream decision still resolves. Decision conditions are rewritten against **all** process resolvers (variables are process-global), but each resolver task is inserted **once**. `ProcessResolverSupport` + `IntentEntities` (shared perspective/PK resolution). Insertion/rewrite happen on a copy of the step list so the glue generator still sees the original paths. A form-only `relation.field` (no decision references it) is a resolver trigger in its own right; one used on a form *outside* any process stays empty (no process → no resolver). The parser validates every form `relation.field` is a one-hop to-one of the form's `forEntity` with the field present on the target (multi-hop rejected) - **except a CROSS-MODEL to-one, whose target fields are unknown at parse time** (#7093): it is resolved at generation like every other cross-model reference, through `ProcessResolverSupport.CrossModelLookup` (`GlueIntentGenerator.resolverCrossModelLookup` does the IO, so the path logic stays unit-testable). The resolved facts are the owner's perspective (its gen data subfolder) and the key type behind the `Number` accessor, and the generated delegate imports the OWNER's `gen..data.` Entity/Repository - `bindResolver` picks that gen folder from the descriptor's `crossModel`/`targetModel`, DEFAULTED to the local one so a `.glue` written before the key existed still renders. A field the owner model does not declare **fails loudly (422)** rather than being skipped: a skipped resolver leaves the BPMN with a service task pointing at a handler nothing generated and the control bound to a variable nothing ever sets. **That argument only holds because the 422 now scrubs the pass** (#7227): the check sits at `@Order(350)`, after the `.edm`/`.model` (200) and the `.bpmn` (300) are written, and `BpmnIntentGenerator` reads the lookup-free `resolvers(model)` overload whose convention fallback yields the resolver task whatever the field is - so until the rollback landed, the refusal left in the workspace precisely the dangling-task artefact it was justified by. The descriptor carries **no `targetProject`**: the generated resolver imports the owner's Entity/Repository out of its generation folder (derived from `targetModel`) and builds no URL, so a project name is a key neither `bindResolver` nor `Resolver.java.template` reads - the same reason `ProcessResolverSupport.CrossModelTarget` does not carry one either. A cross-model HOP link does need it (`GlueGenerator` builds a controller URL from it); a resolver does not. Two things keep the three consumers of `resolvers()` in step - membership does not depend on the lookup (a cross-model path always yields a resolver, or the whole pass throws), and the BPMN generator deliberately calls the lookup-free overload, since it only needs the process/step/handler names. - **`.settings`** (`IntentSettings`, loaded/scaffolded by `IntentGenerationService.loadOrScaffoldSettings`): developer-owned, scaffolded once then preserved (not scrubbed). Holds the `generation` recipe (template id + parameters per model type), per-artefact `overrides` (`{triggers|resolvers|forms}..generate=false` -> skip and reuse a hand-written one), and `userTasks.candidateGroupsExtra` (defaults to `ADMINISTRATOR`, appended to every user-task `candidateGroups`). Loaded into `IntentGenerationContext` before generators run; honored by the Glue/Form/BPMN generators. The Generate endpoint runs the recipes itself against the written model files - **models and code in one call**, through the Java generation pipeline (`ModelGenerationService`) - and returns the `codeGenerations` it ran with each entry's outcome (`generated`, plus `error` when that one failed); a failure is isolated to its entry, since the model files are already on disk. The editor and the Builder read that report instead of replaying a plan. Cross-module tenant-context fix: the Java `@Listener` dispatch (`ListenerClassConsumer`) now runs in the message's tenant context. - **Declarative-glue catalog (notifications, schedules, integrations, inbound arrivals, outbound departures, rollups)** generated as annotated client-Java off the shared `EventBinding`/`NotificationSupport`/`ScheduleSupport`/`Criteria` core, plus `.glue` collections + `template-application-events-java` templates + the the pipeline's collection cases. The canonical showcase is `IntentEngineIT`'s `INTENT_YAML`; mirrored into `dirigiblelabs/sample-intent-model`. See "Status of the catalog". - **Configurable trigger business key + `timestamp` strategy.** `trigger: { businessKey: , businessKeyStrategy: timestamp }` — the started process's BPM business key is a chosen field (PK by default); the `timestamp` strategy mints a `yyyyMMddHHmmss` value when blank. `IntentEngineIT` covers the flagged key, the mint+persist, and the parse rejection. The strategy field is the extension point for future pluggable number generators (sequential / padded / config-prefixed). 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 7e1b2fba321..9d6103f947c 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 @@ -5035,7 +5035,6 @@ private static List> buildResolvers(IntentModel model, Inten // same registry-wide-compile mechanism a notify recipient's relation load uses. entry.put("crossModel", resolver.crossModel()); entry.put("targetModel", resolver.targetModel()); - entry.put("targetProject", resolver.targetProject()); resolvers.add(entry); } return resolvers; @@ -5055,8 +5054,8 @@ private static ProcessResolverSupport.CrossModelLookup resolverCrossModelLookup( return null; } CrossModelSupport.TargetInfo target = CrossModelSupport.resolve(context, uses, relation.getTo()); - return new ProcessResolverSupport.CrossModelTarget(target.perspectiveName(), uses.resolveProject(), uses.getModel(), - target.propertyNames(), target.fkType()); + return new ProcessResolverSupport.CrossModelTarget(target.perspectiveName(), uses.getModel(), target.propertyNames(), + target.fkType()); }; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java index 12e1e551f87..f5535df17e2 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java @@ -12,12 +12,17 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.Map; import java.util.Set; +import org.eclipse.dirigible.components.intent.LoggedValue; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.repository.api.IRepository; import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Per-generation call context handed to every {@link IntentTargetGenerator}. Carries the parsed @@ -36,9 +41,19 @@ * All writes go through {@link #writeModelFile(String, String)}, which records the emitted file * names so {@link IntentGenerationService} can scrub files that a previous generation wrote but the * current one no longer produces. + * + *

+ * Every write also journals the state it replaced, so a pass that is REFUSED - a generator raising + * {@link org.eclipse.dirigible.components.intent.parser.IntentValidationException} - can be undone + * whole by {@link #rollbackWrittenFiles()} (dirigible #7227). Generation runs the generators in + * {@code @Order}, so a check placed in a late generator would otherwise leave the earlier ones' + * output in the workspace next to the 422: an authoring mistake refused at generation must cost the + * developer nothing, exactly as one refused at parse does. */ public final class IntentGenerationContext { + private static final Logger LOGGER = LoggerFactory.getLogger(IntentGenerationContext.class); + /** Repository path of the target project root, e.g. {@code /users/admin/workspace/my-library}. */ private final String projectRoot; @@ -83,6 +98,14 @@ public final class IntentGenerationContext { /** Bare file names written under {@link #projectRoot} during this generation pass. */ private final Set writtenFileNames = new LinkedHashSet<>(); + /** + * What this pass actually CHANGED, keyed by bare file name: the content the file held before the + * pass touched it, or {@code null} when the pass created it. Recorded on the first change of each + * file only, and never for a write that turned out to be byte-identical (there is nothing to undo) + * - so it is exactly the set {@link #rollbackWrittenFiles()} has to put back. + */ + private final Map replacedContent = new LinkedHashMap<>(); + /** * Non-fatal generation issues (e.g. a piece of glue that could not be emitted because a reference * did not resolve) collected during the pass. Surfaced in the generate response so the drop is not @@ -140,10 +163,13 @@ public void writeModelFile(String fileName, String content) { byte[] bytes = content.getBytes(StandardCharsets.UTF_8); IResource existing = repository.getResource(path); if (existing.exists()) { - if (!Arrays.equals(existing.getContent(), bytes)) { + byte[] previous = existing.getContent(); + if (!Arrays.equals(previous, bytes)) { + journal(fileName, previous); existing.setContent(bytes); } } else { + journal(fileName, null); repository.createResource(path, bytes); } writtenFileNames.add(fileName); @@ -168,11 +194,55 @@ public void writeModelFileIfAbsent(String fileName, String content) { String path = projectRoot + "/" + fileName; IResource existing = repository.getResource(path); if (!existing.exists()) { + journal(fileName, null); repository.createResource(path, content.getBytes(StandardCharsets.UTF_8)); } writtenFileNames.add(fileName); } + /** + * Record the state a file held before this pass first changed it - {@code null} meaning it did not + * exist. Only the FIRST change of a file is journaled: the rollback has to restore the state the + * pass started from, not the one an earlier generator of the same pass left behind. + */ + private void journal(String fileName, byte[] previous) { + if (!replacedContent.containsKey(fileName)) { + replacedContent.put(fileName, previous); + } + } + + /** + * Undo every change this pass made at the project root: a file it created is removed, a file it + * overwrote gets its previous content back. Used when the pass is refused as a whole - a generator + * raising {@link org.eclipse.dirigible.components.intent.parser.IntentValidationException} - so the + * 422 leaves the workspace exactly as the developer had it (dirigible #7227), rather than the + * partial model set the generators before the failing one had already written. + * + *

+ * A restore that itself fails is logged and the rest still run: the caller is on its way to + * reporting the authoring error, and one file that could not be put back must not hide it. + */ + void rollbackWrittenFiles() { + if (dryRun || repository == null || projectRoot == null) { + return; + } + for (Map.Entry entry : replacedContent.entrySet()) { + String path = projectRoot + "/" + entry.getKey(); + try { + if (entry.getValue() == null) { + repository.removeResource(path); + } else { + repository.getResource(path) + .setContent(entry.getValue()); + } + } catch (RuntimeException e) { + LOGGER.error("Failed to roll back intent output [{}]", LoggedValue.of(path), e); + } + } + replacedContent.clear(); + writtenFileNames.clear(); + } + /** * Claim an already-present, developer-owned model file: it is neither written nor scrubbed by this * pass. This is the write-once counterpart for a generator that cannot always produce content — it diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java index 071265bc6a7..154cb53eed3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java @@ -39,6 +39,14 @@ * {@code .intent} file itself, code files, and the {@code gen/} / {@code custom/} subfolders (only * direct child resources are considered). Removing a process / form / report / seed from the intent * therefore removes its model file on the next Generate instead of leaving a stale artefact around. + * + *

+ * A pass that is REFUSED writes nothing: an {@link IntentValidationException} out of any generator + * rolls back what the earlier ones already wrote before it leaves as a 422 (dirigible #7227). The + * generators run in {@code @Order}, so without that a check placed in a late generator would leave + * a half-generated model set in the workspace - e.g. the {@code .edm}/{@code .model} and a + * {@code .bpmn} carrying a {@code Resolve<...>} service task whose handler the refused glue pass + * never generated. Refusing at generation must cost the developer no more than refusing at parse. */ @Component public class IntentGenerationService { @@ -144,6 +152,12 @@ public GenerationResult generate(String yaml, String projectRoot, String project } catch (IntentValidationException e) { // A fatal authoring error the developer must fix (e.g. an unresolvable cross-model // dependency) - surface it to the caller (-> 422), do NOT isolate it like a generator bug. + // The pass is refused AS A WHOLE (dirigible #7227): the generators run in @Order, so a + // check in a later one would otherwise leave the earlier ones' output behind - a + // half-generated model set next to the 422, and nothing scrubs it (the scrub below is + // never reached). Undo this pass's writes so the workspace is exactly what it was, the + // way a parse-time refusal leaves it. + context.rollbackWrittenFiles(); throw e; } catch (RuntimeException e) { LOGGER.error("Intent generator [{}] failed for project [{}]", generator.name(), LoggedValue.of(projectName), e); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessResolverSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessResolverSupport.java index c6673e0a929..eabd9938fe1 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessResolverSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/ProcessResolverSupport.java @@ -95,13 +95,12 @@ public interface CrossModelLookup { * accessor). * * @param perspectiveName the target's perspective in the owner model (its gen data subfolder) - * @param project the owner project * @param modelAlias the owner model alias * @param propertyNames the target's PascalCase property names, or null when the owner model was * resolved by naming convention only - the field is then trusted as authored * @param keyType the JDBC type of the target's primary key (e.g. {@code INTEGER} / {@code BIGINT}) */ - public record CrossModelTarget(String perspectiveName, String project, String modelAlias, Set propertyNames, String keyType) { + public record CrossModelTarget(String perspectiveName, String modelAlias, Set propertyNames, String keyType) { } /** @@ -122,12 +121,10 @@ public record CrossModelTarget(String perspectiveName, String project, String mo * @param crossModel whether the target is owned by another model - then the generated resolver * imports the OWNER's generated Entity/Repository package instead of this project's * @param targetModel the owner model alias (empty for a same-model target) - * @param targetProject the owner project (empty for a same-model target) */ public record Resolver(String process, String beforeStep, String token, String variable, String handler, String fkProperty, String targetEntity, String targetField, String targetPerspective, String targetIdAccessor, String ownerEntity, - String ownerPerspective, String ownerKeyProperty, String ownerKeyAccessor, boolean crossModel, String targetModel, - String targetProject) { + String ownerPerspective, String ownerKeyProperty, String ownerKeyAccessor, boolean crossModel, String targetModel) { } /** @@ -236,11 +233,11 @@ private static void addResolver(Map byName, Map byName, Map(target.propertyNames()))); } return new Target(target.perspectiveName(), "BIGINT".equalsIgnoreCase(target.keyType()) ? "longValue" : "intValue", true, - target.modelAlias() == null ? "" : target.modelAlias(), target.project() == null ? "" : target.project()); + target.modelAlias() == null ? "" : target.modelAlias()); } private static RelationIntent toOneRelation(EntityIntent owner, String name) { diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueFormCrossModelHopTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueFormCrossModelHopTest.java index 7df26430d2c..d99bee7ac35 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueFormCrossModelHopTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueFormCrossModelHopTest.java @@ -10,6 +10,7 @@ package org.eclipse.dirigible.components.intent.generator; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; @@ -102,7 +103,10 @@ void theResolverImportsTheOwnerModelsPackage() { assertEquals("Customer", resolver.get("targetPerspective")); assertEquals(Boolean.TRUE, resolver.get("crossModel")); assertEquals("customers", resolver.get("targetModel")); - assertEquals("customers", resolver.get("targetProject")); + // The owner PROJECT is deliberately absent: the generated resolver imports the owner's + // Entity/Repository from its generation folder (derived from targetModel) and builds no URL, + // so a project name would be a descriptor key nothing downstream reads (dirigible #7227). + assertFalse(resolver.containsKey("targetProject"), "the resolver descriptor must carry no unread key, got: " + resolver); assertEquals("intValue", resolver.get("targetIdAccessor")); } @@ -136,7 +140,7 @@ void aLocalRelationFieldCarriesNoCrossModelCoordinates() { .get(0); assertEquals(Boolean.FALSE, resolver.get("crossModel")); assertEquals("", resolver.get("targetModel"), "a local target must leave the model empty so the local gen folder is used"); - assertEquals("", resolver.get("targetProject")); + assertFalse(resolver.containsKey("targetProject")); } /** diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationServiceRefusalTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationServiceRefusalTest.java new file mode 100644 index 00000000000..257fdbbdad8 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationServiceRefusalTest.java @@ -0,0 +1,194 @@ +/* + * 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.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.eclipse.dirigible.components.intent.generator.action.ActionIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.apptest.AppTestIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.bpmn.BpmnIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.csvim.CsvimIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.edm.EdmIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.form.FormIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.generates.GeneratesIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.permission.PermissionIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.print.PrintIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.report.ReportIntentGenerator; +import org.eclipse.dirigible.components.intent.generator.transition.TransitionsIntentGenerator; +import org.eclipse.dirigible.components.intent.parser.IntentValidationException; +import org.eclipse.dirigible.repository.local.LocalRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * A generation pass that is REFUSED must leave the workspace exactly as it found it (dirigible + * #7227). + * + *

+ * The generators run in {@code @Order}, so a generation-time check is reached only after the + * earlier generators have written their model files - and the loop rethrows the + * {@link IntentValidationException} straight to the caller, past the stale-output scrub. The + * reported case is the cross-model {@code relation.field} check (#7093, at {@code @Order(350)}): + * its whole justification is that skipping the resolver would leave the BPMN with a + * {@code Resolve<...>} service task whose handler nothing generated - which is precisely what the + * 422 itself used to leave behind, since {@code BpmnIntentGenerator} (300) emits that task from the + * lookup-free convention regardless of whether the field exists. Refusing at generation must cost + * the developer no more than refusing at parse did. + */ +class IntentGenerationServiceRefusalTest { + + private static final String PROJECT_ROOT = "/users/admin/workspace/sales-invoices"; + + /** The reported document: a task form showing a field of a cross-model to-one. */ + private static final String YAML = """ + name: sales-invoices + uses: + - { model: customers } + entities: + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + relations: + - { name: Customer, kind: manyToOne, to: Customer, model: customers, required: true } + processes: + - name: Send + trigger: { onCreate: SalesInvoice } + steps: + - { name: send, kind: userTask, args: { assignee: clerk, form: SendSalesInvoice } } + - { name: done, kind: end } + forms: + - name: SendSalesInvoice + forEntity: SalesInvoice + fields: [number, Customer, Customer.email] + actions: [send] + """; + + /** The same document naming a field the owner model does not declare - refused at generation. */ + private static final String BROKEN_YAML = YAML.replace("Customer.email", "Customer.mobile"); + + /** The owner model as the customers project generated it. */ + private static final String OWNER_MODEL = """ + { + "model": { + "entities": [ + { + "name": "Customer", + "perspectiveName": "Customer", + "dataName": "CUSTOMERS_CUSTOMER", + "properties": [ + { "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" }, + { "name": "Name", "dataName": "NAME", "dataType": "VARCHAR" }, + { "name": "Email", "dataName": "EMAIL", "dataType": "VARCHAR" } + ] + } + ] + } + } + """; + + @Test + void aRefusedFirstPassLeavesNothingBehind(@TempDir Path root) { + LocalRepository repository = seed(root, BROKEN_YAML); + IntentGenerationService service = service(repository); + Map before = snapshot(root); + + IntentValidationException ex = assertThrows(IntentValidationException.class, + () -> service.generate(BROKEN_YAML, PROJECT_ROOT, "sales-invoices", "workspace", "sales-invoices")); + + assertTrue(ex.getIssues() + .stream() + .anyMatch(issue -> issue.contains("Mobile")), + "the refusal must still name the unknown property, got: " + ex.getIssues()); + // The point of the issue: no .edm/.model from @Order(200) and - above all - no .bpmn from + // @Order(300) carrying a ResolveCustomerMobile service task whose handler was never generated. + Map after = snapshot(root); + assertEquals(before.keySet(), after.keySet(), "a refused pass must leave no generated file behind"); + after.forEach((file, content) -> assertFalse(new String(content, StandardCharsets.UTF_8).contains("esolveCustomerMobile"), + "[" + file + "] must not carry the dangling resolver the refusal exists to prevent")); + } + + @Test + void aRefusedRegenerationRestoresThePreviousOutput(@TempDir Path root) { + LocalRepository repository = seed(root, YAML); + IntentGenerationService service = service(repository); + // A first pass that succeeds - the workspace now holds a full, consistent model set. + service.generate(YAML, PROJECT_ROOT, "sales-invoices", "workspace", "sales-invoices"); + Map generated = snapshot(root); + assertTrue(generated.keySet() + .stream() + .anyMatch(file -> file.endsWith("Send.bpmn")), + "the successful pass must have written the process: " + generated.keySet()); + + assertThrows(IntentValidationException.class, + () -> service.generate(BROKEN_YAML, PROJECT_ROOT, "sales-invoices", "workspace", "sales-invoices")); + + Map after = snapshot(root); + assertEquals(generated.keySet(), after.keySet(), "a refused regeneration must neither add nor remove a file"); + generated.forEach((file, content) -> assertArrayEquals(content, after.get(file), + "a refused regeneration must leave [" + file + "] as the last good pass wrote it")); + } + + /** The project as the developer has it: the intent, and the sibling owner project's model. */ + private static LocalRepository seed(Path root, String yaml) { + LocalRepository repository = new LocalRepository(root.toString(), true); + repository.createResource(PROJECT_ROOT + "/sales-invoices.intent", yaml.getBytes(StandardCharsets.UTF_8)); + repository.createResource("/users/admin/workspace/customers/customers.model", OWNER_MODEL.getBytes(StandardCharsets.UTF_8)); + return repository; + } + + /** + * The generators in their real {@code @Order} - the ordering IS the defect's mechanism, so the + * harness must not quietly fix it: EDM (200) and BPMN (300) write before the glue pass (350) + * refuses. + */ + private static IntentGenerationService service(LocalRepository repository) { + return new IntentGenerationService(List.of(new EdmIntentGenerator(), new BpmnIntentGenerator(), new GlueIntentGenerator(), + new ServiceTaskHandlerGenerator(), new CalculatedActionStubGenerator(), new FormIntentGenerator(), + new ActionIntentGenerator(), new GeneratesIntentGenerator(), new TransitionsIntentGenerator(), new ReportIntentGenerator(), + new PermissionIntentGenerator(), new CsvimIntentGenerator(), new PrintIntentGenerator(), new AppTestIntentGenerator()), + repository, null); + } + + /** Every file under the repository root, relative path to content. */ + private static Map snapshot(Path root) { + Map files = new LinkedHashMap<>(); + try (Stream tree = Files.walk(root)) { + tree.filter(Files::isRegularFile) + .sorted() + .forEach(file -> { + try { + files.put(root.relativize(file) + .toString(), + Files.readAllBytes(file)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return files; + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index b265aea5d72..2e1853c7979 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -130,6 +130,9 @@ class IntentEngineIT extends IntegrationTest { private static final String DEPENDENCY_PROJECT = "quotations"; private static final String DEPENDENCY_PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + DEPENDENCY_PROJECT; + /** A third project holding only a hand-written owner {@code .model} - see #7227's test. */ + private static final String OWNER_PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/partners"; + private static final String DEPENDENCY_GENERATE_URL = "/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + DEPENDENCY_PROJECT + "&path=app.intent"; /** @@ -4358,6 +4361,87 @@ void generate_rejects_invalid_intents_with_the_issue_list() { .body("issues", hasItem("entity [A] field [x] has unknown type [nosuchtype]"))); } + /** + * A GENERATION-time refusal leaves the workspace as it found it (dirigible #7227). + * + *

+ * The generators run in {@code @Order}, so a check in a late one is reached only after the earlier + * ones have written into the project - and the 422 goes straight to the caller, past the + * stale-output scrub. The reported case is the cross-model {@code relation.field} check at + * {@code @Order(350)}, whose whole justification is that skipping the resolver would leave the BPMN + * with a {@code Resolve<...>} service task whose handler nothing generated: the {@code .bpmn} is + * written at 300 from the lookup-free resolver convention, which yields that task whether or not + * the owner declares the field, so the refusal itself used to leave behind exactly the artefact it + * was justified by. Nothing is written now - the state a parse-time refusal leaves. + */ + @Test + void a_generation_time_refusal_leaves_no_model_files_behind() { + // The owner model has to really exist for the check to be reachable at all: an unresolvable + // `uses` is a different refusal, one raised before any owner property list is read. It is + // written by hand, in a project of its own, rather than generated from a sibling intent - the + // shared dependency project is where the bootstrap test needs a model to be ABSENT, and one + // test's owner model is the other test's precondition. + repository.createResource(OWNER_PROJECT_PATH + "/partners.model", CROSS_MODEL_OWNER_MODEL.getBytes(StandardCharsets.UTF_8)); + writeIntent(UNKNOWN_CROSS_MODEL_FIELD_INTENT); + + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(422) + .body("issues", hasItem(containsString("Mobile")))); + + assertFalse(resource("Send.bpmn").exists(), + "the refused pass must leave no .bpmn - the dangling Resolve<...> task is the artefact it exists to prevent"); + assertFalse(resource("billing.edm").exists(), "nor the .edm an earlier generator had already written"); + assertFalse(resource("billing.model").exists(), "nor its .model twin"); + assertFalse(resource("billing.settings").exists(), "nor the settings the pass scaffolded on its way in"); + } + + /** The owner half as its own project generated it: a Customer with an e-mail and no mobile. */ + private static final String CROSS_MODEL_OWNER_MODEL = """ + { + "model": { + "entities": [ + { + "name": "Customer", + "perspectiveName": "Customer", + "dataName": "PARTNERS_CUSTOMER", + "properties": [ + { "name": "Id", "dataName": "ID", "dataType": "INTEGER", "dataPrimaryKey": "true" }, + { "name": "Name", "dataName": "NAME", "dataType": "VARCHAR" }, + { "name": "Email", "dataName": "EMAIL", "dataType": "VARCHAR" } + ] + } + ] + } + } + """; + + /** A task form showing a cross-model field the owner model does not declare. */ + private static final String UNKNOWN_CROSS_MODEL_FIELD_INTENT = """ + name: billing + uses: + - { model: partners } + entities: + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, length: 100 } + relations: + - { name: Customer, kind: manyToOne, to: Customer, model: partners, required: true } + processes: + - name: Send + trigger: { onCreate: Invoice } + steps: + - { name: send, kind: userTask, args: { assignee: clerk, form: SendInvoice } } + - { name: done, kind: end } + forms: + - name: SendInvoice + forEntity: Invoice + fields: [number, Customer, Customer.mobile] + actions: [send] + """; + @Test void calculated_field_action_emits_an_imports_backed_callout_in_the_repository() { // A field can be computed server-side by a hand-written CalculatedField action instead of a @@ -5724,5 +5808,8 @@ void removeProject() { if (repository.hasCollection(DEPENDENCY_PROJECT_PATH)) { repository.removeCollection(DEPENDENCY_PROJECT_PATH); } + if (repository.hasCollection(OWNER_PROJECT_PATH)) { + repository.removeCollection(OWNER_PROJECT_PATH); + } } }