From b6675e4aa519ce513b509d7f337e850cc724671f Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 08:48:46 +0300 Subject: [PATCH 01/28] Add engine-intent: source-of-truth .intent artefact above EDM/BPMN/form Introduces components/engine/engine-intent as a scaffolding for a new authoring layer: a single .intent YAML file at a project root drives the regeneration of every other model (EDM, DSM, BPMN, form, report, generated TS/Java) under gen/. Developers author only the intent; everything else is "gen" output owned by the regenerator. What's in: - Intent JPA artefact + repository + service (table DIRIGIBLE_INTENTS, artefact type "intent", file extension .intent) - IntentSynchronizer extends BaseSynchronizer; regeneration pass runs in finishing() so the gen/ output is on disk for the next reconciliation cycle (the orchestrator's file walk happens once per cycle). - SynchronizersOrder.INTENT = 5, ahead of every other artefact type so the regenerated files participate in the next cycle before any consumer synchronizer scans. - IntentTargetGenerator SPI + IntentRegenerationService + per-call IntentGenerationContext. Generators are Spring beans, discovered and ordered via @Order. Each owns one slice (entities, processes, forms, reports, permissions, controllers) and must be idempotent + scoped to the gen/ subtree. - IntentModel POJOs covering the v1 shape: entities (+ fields, relations), processes (+ steps), forms, reports, permissions. - IntentParser uses SnakeYAML's SafeConstructor (blocks !!type / !!new tags - intent comes from LLM output and human paste, deserialisation must never be a code-execution surface) then round-trips through Gson via JsonHelper so the typed-POJO mapping lives in a single place. Wired into components/pom.xml, modules/pom.xml dependencyManagement, and group/group-engines/pom.xml. No concrete IntentTargetGenerator implementations yet - only the SPI. Concrete generators per slice, the IDE perspective (Mermaid + Claude chat + patch preview), the /custom/ escape-hatch directory, and the same-cycle-visibility orchestrator hook are all flagged as follow-ups in the module's CLAUDE.md. CLAUDE.md in the new module captures the design decisions verbatim from the design conversation: why this exists, the three things any change here must reckon with (expressiveness ceiling, LLM determinism, structured not free text), the chosen format (YAML), the open same-cycle vs next-cycle visibility question, the v1 YAML shape, and the things-not-to-do list. Co-Authored-By: Claude Opus 4.7 --- .../base/synchronizer/SynchronizersOrder.java | 6 + components/engine/engine-intent/CLAUDE.md | 169 ++++++++++++++ components/engine/engine-intent/about.html | 29 +++ components/engine/engine-intent/pom.xml | 36 +++ .../components/intent/domain/Intent.java | 94 ++++++++ .../generator/IntentGenerationContext.java | 65 ++++++ .../generator/IntentRegenerationService.java | 71 ++++++ .../generator/IntentTargetGenerator.java | 38 +++ .../components/intent/model/EntityIntent.java | 57 +++++ .../components/intent/model/FieldIntent.java | 91 ++++++++ .../components/intent/model/FormIntent.java | 66 ++++++ .../components/intent/model/IntentModel.java | 100 ++++++++ .../intent/model/PermissionIntent.java | 50 ++++ .../intent/model/ProcessIntent.java | 60 +++++ .../intent/model/RelationIntent.java | 63 +++++ .../components/intent/model/ReportIntent.java | 76 ++++++ .../components/intent/model/StepIntent.java | 50 ++++ .../intent/parser/IntentParser.java | 50 ++++ .../intent/repository/IntentRepository.java | 21 ++ .../intent/service/IntentService.java | 28 +++ .../synchronizer/IntentSynchronizer.java | 217 ++++++++++++++++++ components/group/group-engines/pom.xml | 4 + components/pom.xml | 1 + modules/pom.xml | 5 + 24 files changed, 1447 insertions(+) create mode 100644 components/engine/engine-intent/CLAUDE.md create mode 100644 components/engine/engine-intent/about.html create mode 100644 components/engine/engine-intent/pom.xml create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FieldIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FormIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PermissionIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ProcessIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StepIntent.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/service/IntentService.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java diff --git a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java index f068ebd71d..2983907beb 100644 --- a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java +++ b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java @@ -14,6 +14,12 @@ */ public interface SynchronizersOrder { + /** + * The intent. Runs first so its regenerated {@code gen/} output is in place for downstream + * synchronizers on the next reconciliation cycle. + */ + int INTENT = 5; + /** The extensionpoint. */ int EXTENSIONPOINT = 10; diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md new file mode 100644 index 0000000000..184a94d7d7 --- /dev/null +++ b/components/engine/engine-intent/CLAUDE.md @@ -0,0 +1,169 @@ +# engine-intent + +A single `.intent` YAML file at a project root becomes the source of truth for every other artefact in that project. EDM, DSM, BPMN, forms, reports, generated TS / Java are all `gen/` artefacts owned by this engine - developers must not edit them. This is one altitude higher than the existing pattern (where the standard models were the source and HTML / SQL / ORM mappings were the gen output): here the standard models themselves are gen. + +The whole feature lives in `org.eclipse.dirigible.components.intent.*`. + +## Design context (what we agreed) + +Distilled from the chat that produced the initial scaffold. Read this BEFORE designing additional pieces - half of these decisions are not obvious from the code. + +### Why this exists + +Dirigible is already model-driven (the synchronizer model = "declarations on disk → running app"). Adding an intent layer above EDM/BPMN/form/DSM is the natural next abstraction. The second half of the pipeline (intent → standard models → generated app) reuses what already exists: project templates, decorator-driven scaffolding, the TS/Java SDKs. No new runtime concept - just a new authoring layer above the existing ones. + +The dream is "no code, no modelling - just prompt": user describes what they want in natural language to Claude (or any LLM); Claude proposes a patch to `.intent`; the user accepts; the synchronizer regenerates the whole app under `gen/`; Mermaid renders the intent for a quick read-only visual. + +### Three things any non-trivial change here must reckon with + +1. **Expressiveness ceiling - the thing that kills MDE projects.** Every EDM attribute, every BPMN gateway condition, every form validator, every report aggregation, every permission rule has to be representable in `.intent`, because the developer can NOT escape to `gen/`. Real apps always have one weird bit. We chose the **escape-hatch directory** approach over union-of-everything: a `/custom/` sibling to `/gen/` will hold hand-written code preserved across regenerations, intent declares hook points, custom files supply implementations. Pure MDE has been tried for thirty years and the escape hatch always wins. The `/custom/` folder is NOT yet wired (out of scope for this skeleton) but every generator must be written assuming it exists - never emit into `gen/` something that should have been overridable. + +2. **LLM determinism - edit shape, not file shape.** "Add a `country` field to `Customer`" must produce a one-line diff to `app.intent`, not a re-emitted file with entities reordered. Claude's job is **proposing a patch** to the intent (structured operations / unified diff), not regenerating it. The UI should show patch + Mermaid preview + accept/reject before applying. The structured-edit panel in the IDE is the power-user fallback when Claude misunderstands. The intent JSON is therefore arranged so diffs are minimal and stable: entities/processes/forms/reports/permissions are arrays (preserved order), nested fields use object literals, and the parser does not normalize field order. Do not introduce auto-sorting or reformatting on save. + +3. **Intent is structured, not free text.** The LLM converts NL → structured YAML; transforms from intent to EDM/BPMN/form are pure deterministic functions; Mermaid renders from the same model. Three distinct stages. The LLM is replaceable / optional - the intent format must be authorable by a human in a structured editor too. + +### Concrete agreements + +- **YAML, not JSON.** Optimised for human authoring (comments, multi-line strings, no quote noise, friendlier LLM diffs). Parsed via SnakeYAML's `SafeConstructor` (already on the classpath transitively via Spring Boot) then round-tripped through Gson to land in the typed POJOs - one mapping path for both surfaces. +- **Safe YAML loading is non-negotiable.** `IntentParser` constructs SnakeYAML with `SafeConstructor`, which blocks `!!type` / `!!new` tags. Intents arrive from LLM output and human paste; YAML deserialisation must never become a code-execution surface. Do not swap to `Constructor` for "ergonomics". +- **One `.intent` file per project**, at the project root. There is no plan to support multiple intents per project - the whole model lives in one place so the LLM has the whole picture to diff against. (Re-evaluate if intents grow past ~2000 lines in practice; until then, one file.) +- **Everything else under `/gen/` is owned by the regeneration pass.** Developers must not edit `gen/`; the IDE surface should mark it read-only. The synchronizer does NOT need to enforce this - anything hand-edited there is overwritten on next intent change anyway. +- **Existing projects without an intent stay "classic"** (hand-edit EDM/BPMN/form as before). An "intent project" is detected by the presence of `app.intent` at project root. A future `reverse-engineer intent` command can scan EDM/BPMN/form and propose an intent file to migrate; out of scope for now. +- **Mermaid renders the intent for visualisation**, read-only. We do NOT build a Mermaid round-trip editor (it is a poor authoring surface). Editing is via the LLM prompt + structured panel; the existing modelers are NOT re-used for intent projects (they would let developers edit gen/ in disguise). +- **Run-once-fix-it via Claude.** When something can't be expressed, the answer is to extend `.intent` (add a field to the schema, add a generator that consumes it), not to leak into gen/. + +## Module layout + +``` +components/engine/engine-intent/ +├── pom.xml # depends only on core-base, core-database, core-repository +├── about.html +├── CLAUDE.md # this file +└── src/main/java/org/eclipse/dirigible/components/intent/ + ├── domain/Intent.java # JPA entity; ARTEFACT_TYPE = "intent", table DIRIGIBLE_INTENTS + ├── repository/IntentRepository.java # Spring Data + ├── service/IntentService.java # CRUD via BaseArtefactService + ├── model/ # POJOs for the intent document (Gson-mapped after YAML → Map → JSON round-trip) + │ ├── IntentModel.java # root: entities / processes / forms / reports / permissions + │ ├── EntityIntent.java + │ ├── FieldIntent.java + │ ├── RelationIntent.java + │ ├── ProcessIntent.java + │ ├── StepIntent.java + │ ├── FormIntent.java + │ ├── ReportIntent.java + │ └── PermissionIntent.java + ├── parser/IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (Gson) + ├── generator/ + │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) + │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + IRepository + │ └── IntentRegenerationService.java # collects every SPI bean and runs them in @Order + └── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() +``` + +No generator implementations live in this module yet - only the SPI. Concrete generators (entity → `gen/.entity` + `gen/.dsm`, process → `gen/.bpmn`, form → `gen/.form`, report → `gen/.report`, permission → `gen/.roles` + `gen/.access`, controller → `gen/.controller.ts`) belong in follow-up modules or sibling generator packages that depend on this one plus the relevant target artefact module. They are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order`. + +## Wiring + +- **Artefact type string `intent`, file extension `.intent`, JPA table `DIRIGIBLE_INTENTS`.** +- **`SynchronizersOrder.INTENT = 5`** - lower than every other artefact (EXTENSIONPOINT = 10 is the previous floor) so the intent's regenerated `gen/` files are on disk before any other synchronizer starts the NEXT cycle. +- **`IntentSynchronizer extends BaseSynchronizer`** - single-tenant. Intent itself carries no runtime state; downstream synchronizers handle their own tenancy. +- **Module registered in:** + - `components/pom.xml` (Maven reactor) + - `components/group/group-engines/pom.xml` (so `build/application` picks it up via the group aggregator) + +## Synchronizer flow + +1. `parseImpl(location, content)` - reads the YAML bytes, persists `Intent` with the raw payload in `INTENT_CONTENT`, marks the intent dirty for `finishing()`. Structural validation is deferred to `IntentParser.parse` in the regeneration pass so that a malformed YAML body doesn't block the artefact from being recorded. +2. `completeImpl(wrapper, phase)` - pure book-keeping (CREATED / UPDATED / DELETED). No runtime side-effects. +3. `finishing()` - for every intent marked dirty this cycle, calls `IntentRegenerationService.regenerate(intent)`. Each registered `IntentTargetGenerator` writes its slice under `/gen/`. Failures in one generator are logged and isolated; the others still run. + +### Open design question: same-cycle vs next-cycle visibility + +`SynchronizationProcessor` walks the repository **once per cycle**, dispatching every file to the first synchronizer whose `isAccepted` matches. Files written **during** the cycle - including everything `IntentRegenerationService` writes under `gen/` - are NOT visible to other synchronizers in the same cycle. They are picked up on the **next** reconciliation. + +This is acceptable for the scaffold: developer publishes, intent regenerates, second reconciliation (auto or manual) brings the rest live. UX-wise it is a half-beat behind. Real options to fix: + +1. **Pre-pass orchestration.** Have `SynchronizationProcessor` invoke a new "before walk" hook on each synchronizer; `IntentSynchronizer` regenerates there. Cleanest, requires editing `core-initializers`. +2. **Self-triggered second cycle.** At the end of `IntentSynchronizer.finishing()`, if any intent was dirty, schedule a follow-up `forceProcessSynchronizers()` call. Simple, risks a tight loop if not guarded; use the existing `processing` AtomicBoolean. +3. **Live with two cycles.** Document it; the IDE "publish" button fires two `forceProcessSynchronizers()` calls back to back. + +The skeleton picks option 3 because it is the only one that does not touch other modules. Pick option 1 when the surface is otherwise stable. + +## Intent YAML shape (v1 draft) + +The shape the model POJOs serialize to / deserialize from. Keep field names stable - this is the schema the LLM is prompted against. Every collection defaults to empty so partial intents (e.g. entities only) parse cleanly. Field names are camelCase to match the POJOs after the SnakeYAML → Map → Gson → POJO round-trip (Gson does not do snake_case-to-camelCase rewriting by default). + +```yaml +name: orders +description: Order management with approval workflow +version: 1 + +entities: + - name: Customer + description: Buyer account + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: country, type: string, length: 2 } + relations: + - { name: orders, kind: oneToMany, to: Order } + +processes: + - name: OrderApproval + trigger: { onCreate: Order } + steps: + - name: managerReview + kind: userTask + args: { assignee: manager, form: ApproveOrder } + - name: bigOrder + kind: decision + args: { if: "amount > 10000", then: cfoReview } + - name: cfoReview + kind: userTask + args: { assignee: cfo, form: ApproveOrder } + +forms: + - name: ApproveOrder + forEntity: Order + fields: [items, total] + actions: [approve, reject] + +reports: + - name: OrdersByCountry + source: Order + dimensions: [customer.country] + measures: ["count(*)", "sum(total)"] + +permissions: + - { role: Sales, can: [Customer:read, Customer:write, Order:create] } + - { role: Manager, can: [Order:approve] } +``` + +Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `decimal`, `boolean`, `date`, `uuid`. Generators map them to JDBC + EDM types. Relation kinds: `oneToMany`, `manyToOne`, `oneToOne`, `manyToMany`. Step kinds: `userTask`, `serviceTask`, `decision`, `script`, `end`. + +### YAML authoring rules + +- **Comments are allowed and encouraged.** Lines starting with `#` survive a SnakeYAML load → JSON round-trip only as dropped content, so they are NOT preserved across regeneration of the intent itself - but since the intent is the only authored artefact and no tool ever rewrites it, comments authored by a developer stay put. The LLM patch path must respect the surrounding comments. +- **No anchors / aliases (`&foo` / `*foo`)** at v1. They cut copy-paste corners for the author but make the file far harder for the LLM to diff. If duplication becomes painful, introduce a top-level `defaults:` block rather than YAML's structural aliasing. +- **No multi-document YAML (`---`).** One intent file, one YAML document. +- **Tags forbidden.** Already enforced by `SafeConstructor`; mentioned here so a future reader does not "fix" it. + +## Things to not do + +- **Don't make intent multi-tenant.** Authoring is single-tenant; generated artefacts handle their own tenancy. +- **Don't let intent rewrite or sort itself.** Diff stability matters - the LLM has to produce minimal patches, which only works if the on-disk shape is stable. No auto-formatting, no field reordering. +- **Don't generate outside `gen/`.** The synchronizer relies on this to scrub stale gen/ files between cycles without risking developer-authored files. `IntentGenerationContext.getGenRoot()` is the only path generators write to. +- **Don't add a Mermaid editor.** Mermaid is for visualisation. Authoring is prompt + structured panel. +- **Don't reuse the existing modelers for intent projects.** That would re-expose `gen/` as an authoring surface and undo the whole point. +- **Don't read env vars or system properties directly** - go through `DirigibleConfig` per the platform-wide rule. + +## Follow-ups not in this skeleton + +- Concrete `IntentTargetGenerator` implementations per slice (entities, processes, forms, reports, permissions, controllers). +- IDE perspective with Mermaid renderer + Claude chat + patch preview + accept/reject. Lives in `components/ui/perspective-intent` once the generators land. +- Read-only Monaco model for paths under `**/gen/**`. +- `/custom/` escape-hatch directory + per-slice hook points in the generators. +- `reverse-engineer intent` command for migrating classic projects. +- Same-cycle visibility (open design question above). +- Schema validation on parse (currently the parser accepts anything SnakeYAML + Gson can map). diff --git a/components/engine/engine-intent/about.html b/components/engine/engine-intent/about.html new file mode 100644 index 0000000000..b89d656fe6 --- /dev/null +++ b/components/engine/engine-intent/about.html @@ -0,0 +1,29 @@ + + + + + +About + + +

About This Content

+ +

June 11, 2026

+

License

+ +

The Eclipse Foundation makes available all content in this plug-in ("Content"). Unless otherwise +indicated below, the Content is provided to you under the terms and conditions of the +Eclipse Public License Version 2.0 ("EPL"). A copy of the EPL is available +at http://www.eclipse.org/legal/epl-v20.html. +For purposes of the EPL, "Program" will mean the Content.

+ +

If you did not receive this Content directly from the Eclipse Foundation, the Content is +being redistributed by another party ("Redistributor") and different terms and conditions may +apply to your use of any object code in the Content. Check the Redistributor's license that was +provided with the Content. If no such license exists, contact the Redistributor. Unless otherwise +indicated below, the terms and conditions of the EPL still apply to any source code in the Content +and such source code may be obtained at http://www.eclipse.org.

+ + + diff --git a/components/engine/engine-intent/pom.xml b/components/engine/engine-intent/pom.xml new file mode 100644 index 0000000000..7c1d7042f7 --- /dev/null +++ b/components/engine/engine-intent/pom.xml @@ -0,0 +1,36 @@ + + + + dirigible-components-parent + org.eclipse.dirigible + 14.0.0-SNAPSHOT + ../../pom.xml + + + Components - Engine - Intent + dirigible-components-engine-intent + 4.0.0 + + + + + org.eclipse.dirigible + dirigible-components-core-base + + + org.eclipse.dirigible + dirigible-components-core-database + + + org.eclipse.dirigible + dirigible-components-core-repository + + + + + ../../../licensing-header.txt + ../../../ + + diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java new file mode 100644 index 0000000000..7ed5e5c424 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java @@ -0,0 +1,94 @@ +/* + * 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.domain; + +import org.eclipse.dirigible.components.base.artefact.Artefact; + +import com.google.gson.annotations.Expose; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +/** + * Persisted projection of an {@code .intent} YAML file. The file itself is the only artefact a + * developer authors; every downstream model (entity, schema, BPMN, form, report, controller) is + * regenerated by {@code IntentSynchronizer} into the {@code gen/} folder of the same project. + */ +@Entity +@Table(name = "DIRIGIBLE_INTENTS") +public class Intent extends Artefact { + + /** Artefact type discriminator stored in {@code ARTEFACT_TYPE}. */ + public static final String ARTEFACT_TYPE = "intent"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "INTENT_ID", nullable = false) + private Long id; + + /** + * Raw YAML payload of the {@code .intent} file, post-placeholder-expansion. Persisted so the + * regeneration step can run from the DB row without re-reading the repository. + */ + @Lob + @Column(name = "INTENT_CONTENT", columnDefinition = "CLOB", nullable = false) + @Expose + private String content; + + /** + * Transient typed view of {@link #content}, hydrated by {@code IntentParser} for the regeneration + * pass. Not persisted. + */ + @Transient + private transient Object model; + + public Intent() {} + + public Intent(String location, String name, String description, String content) { + super(location, name, ARTEFACT_TYPE, description, null); + this.content = content; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public Object getModel() { + return model; + } + + public void setModel(Object model) { + this.model = model; + } + + @Override + public String toString() { + return "Intent{" + "id=" + id + ", location='" + location + '\'' + ", name='" + name + '\'' + ", type='" + type + '\'' + ", key='" + + key + '\'' + ", lifecycle=" + lifecycle + '}'; + } +} 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 new file mode 100644 index 0000000000..cb2e884818 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java @@ -0,0 +1,65 @@ +/* + * 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 org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.repository.api.IRepository; + +/** + * Per-regeneration call context handed to every {@link IntentTargetGenerator}. Carries the parsed + * intent, the originating artefact (for location / key metadata), the project root inside the + * Dirigible repository, and a handle to {@link IRepository} for writing files under {@code gen/}. + * + *

+ * Generators are forbidden from writing anywhere other than {@code /gen/} - the + * synchronizer relies on this to scrub stale gen/ files between cycles without risking + * developer-authored files. + */ +public final class IntentGenerationContext { + + /** Repository sub-path of the project root, e.g. {@code /registry/public/orders}. */ + private final String projectRoot; + + /** Repository sub-path of the gen folder, always {@code /gen}. */ + private final String genRoot; + + private final Intent intent; + private final IntentModel model; + private final IRepository repository; + + public IntentGenerationContext(Intent intent, IntentModel model, String projectRoot, IRepository repository) { + this.intent = intent; + this.model = model; + this.projectRoot = projectRoot; + this.genRoot = projectRoot + "/gen"; + this.repository = repository; + } + + public Intent getIntent() { + return intent; + } + + public IntentModel getModel() { + return model; + } + + public String getProjectRoot() { + return projectRoot; + } + + public String getGenRoot() { + return genRoot; + } + + public IRepository getRepository() { + return repository; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java new file mode 100644 index 0000000000..24b8eacf15 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import java.util.List; + +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.repository.api.IRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Orchestrates the regeneration pass for a single {@link Intent}. Hands every registered + * {@link IntentTargetGenerator} the same {@link IntentGenerationContext} in {@code @Order} order + * and isolates per-generator failures so one broken slice does not block the others. + */ +@Component +public class IntentRegenerationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntentRegenerationService.class); + + private final List generators; + private final IRepository repository; + + public IntentRegenerationService(List generators, IRepository repository) { + this.generators = generators; + this.repository = repository; + } + + /** + * Re-emit every gen/ artefact for the given intent. The intent's {@code location} is used to derive + * the project root ({@code /registry/public//...}). + * + * @param intent the intent whose gen/ output should be refreshed + */ + public void regenerate(Intent intent) { + IntentModel model = IntentParser.parse(intent.getContent()); + intent.setModel(model); + String projectRoot = resolveProjectRoot(intent.getLocation()); + IntentGenerationContext context = new IntentGenerationContext(intent, model, projectRoot, repository); + LOGGER.info("Regenerating gen/ for intent [{}] under [{}] via {} generator(s)", intent.getName(), projectRoot, generators.size()); + for (IntentTargetGenerator generator : generators) { + try { + generator.generate(context); + } catch (RuntimeException e) { + LOGGER.error("Intent generator [{}] failed for intent [{}]", generator.name(), intent.getName(), e); + } + } + } + + /** + * Strip the file segment from an intent location, returning the project root path. + */ + private static String resolveProjectRoot(String location) { + if (location == null) { + return ""; + } + int lastSlash = location.lastIndexOf('/'); + return lastSlash <= 0 ? location : location.substring(0, lastSlash); + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java new file mode 100644 index 0000000000..879e5173b5 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java @@ -0,0 +1,38 @@ +/* + * 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; + +/** + * SPI implemented by every target-artefact generator. Each generator owns one slice of the {@code + * gen/} output - entities, processes, forms, reports, permissions, controllers, schemas - and is + * idempotent: calling {@link #generate(IntentGenerationContext)} twice for the same intent must + * produce byte-identical files. + * + *

+ * Generators are Spring beans, discovered via classpath component scanning and aggregated by + * {@link IntentRegenerationService}. Order across generators (entities before forms before + * controllers, etc.) is established by {@code @Order} on each implementation. + */ +public interface IntentTargetGenerator { + + /** + * A short, stable identifier for the slice this generator owns. Used in logs and for + * future-proofing (e.g. enabling / disabling individual slices via configuration). + */ + String name(); + + /** + * Regenerate this generator's slice of the {@code gen/} output for the given intent. Writes + * exclusively under {@link IntentGenerationContext#getGenRoot()}. + * + * @param context the per-regeneration call context + */ + void generate(IntentGenerationContext context); +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java new file mode 100644 index 0000000000..e7809d6970 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EntityIntent.java @@ -0,0 +1,57 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Domain entity declaration in an {@code .intent}. Generates one EDM entity, one DSM table, one TS + * or Java entity decorator, and a default repository / controller pair downstream. + */ +public class EntityIntent { + + private String name; + private String description; + private List fields = new ArrayList<>(); + private List relations = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getFields() { + return fields; + } + + public void setFields(List fields) { + this.fields = fields == null ? new ArrayList<>() : fields; + } + + public List getRelations() { + return relations; + } + + public void setRelations(List relations) { + this.relations = relations == null ? new ArrayList<>() : relations; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FieldIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FieldIntent.java new file mode 100644 index 0000000000..f9dee5c9f1 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FieldIntent.java @@ -0,0 +1,91 @@ +/* + * 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; + +/** + * Single attribute on an {@link EntityIntent}. {@link #type} carries a logical type string + * ({@code string}, {@code integer}, {@code decimal}, {@code boolean}, {@code date}, {@code uuid}, + * {@code text}) that the entity generator maps to JDBC and EDM types. + */ +public class FieldIntent { + + private String name; + private String type; + private boolean required; + private boolean primaryKey; + private boolean generated; + private Integer length; + private String defaultValue; + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public boolean isPrimaryKey() { + return primaryKey; + } + + public void setPrimaryKey(boolean primaryKey) { + this.primaryKey = primaryKey; + } + + public boolean isGenerated() { + return generated; + } + + public void setGenerated(boolean generated) { + this.generated = generated; + } + + public Integer getLength() { + return length; + } + + public void setLength(Integer length) { + this.length = length; + } + + public String getDefaultValue() { + return defaultValue; + } + + public void setDefaultValue(String defaultValue) { + this.defaultValue = defaultValue; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FormIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FormIntent.java new file mode 100644 index 0000000000..4cdd057337 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/FormIntent.java @@ -0,0 +1,66 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Form / screen declaration. {@link #forEntity} ties the form to an {@link EntityIntent} so the + * form generator can pick fields by name and emit a working CRUD view by default. + */ +public class FormIntent { + + private String name; + private String forEntity; + private String description; + private List fields = new ArrayList<>(); + private List actions = new ArrayList<>(); + + 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 String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getFields() { + return fields; + } + + public void setFields(List fields) { + this.fields = fields == null ? new ArrayList<>() : fields; + } + + public List getActions() { + return actions; + } + + 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/IntentModel.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java new file mode 100644 index 0000000000..64eae7a34a --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -0,0 +1,100 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Root of the {@code .intent} YAML document. Every collection defaults to an empty list so partial + * intents (e.g. entities-only) parse cleanly. The whole tree is intentionally shallow and uniform + * so generators can dispatch by collection rather than by tree-walk. + */ +public class IntentModel { + + /** Optional intent identifier; defaults to the file's base name. */ + private String name; + + /** Optional one-line description shown in the IDE preview pane. */ + private String description; + + /** Schema version of the intent format. {@code 1} for the current draft. */ + private int version = 1; + + private List entities = new ArrayList<>(); + private List processes = new ArrayList<>(); + private List forms = new ArrayList<>(); + private List reports = new ArrayList<>(); + private List permissions = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public int getVersion() { + return version; + } + + public void setVersion(int version) { + this.version = version; + } + + public List getEntities() { + return entities; + } + + public void setEntities(List entities) { + this.entities = entities == null ? new ArrayList<>() : entities; + } + + public List getProcesses() { + return processes; + } + + public void setProcesses(List processes) { + this.processes = processes == null ? new ArrayList<>() : processes; + } + + public List getForms() { + return forms; + } + + public void setForms(List forms) { + this.forms = forms == null ? new ArrayList<>() : forms; + } + + public List getReports() { + return reports; + } + + public void setReports(List reports) { + this.reports = reports == null ? new ArrayList<>() : reports; + } + + public List getPermissions() { + return permissions; + } + + public void setPermissions(List permissions) { + this.permissions = permissions == null ? new ArrayList<>() : permissions; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PermissionIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PermissionIntent.java new file mode 100644 index 0000000000..15ae5e286c --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PermissionIntent.java @@ -0,0 +1,50 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Permission grant. {@link #role} is the role name (e.g. {@code Sales}, {@code Administrator}); + * {@link #can} is a list of {@code Resource:action} tokens (e.g. {@code Customer:read}, + * {@code Order:write}, {@code OrderApproval:start}). Generates {@code .roles} + {@code .access} + * artefacts and {@code @Roles} annotations on generated controllers. + */ +public class PermissionIntent { + + private String role; + private List can = new ArrayList<>(); + private String description; + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public List getCan() { + return can; + } + + public void setCan(List can) { + this.can = can == null ? new ArrayList<>() : can; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ProcessIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ProcessIntent.java new file mode 100644 index 0000000000..1a528f9a4f --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ProcessIntent.java @@ -0,0 +1,60 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Process / workflow declaration. Generates a {@code .bpmn} and any supporting forms / listeners. + * {@link #trigger} is a free-form map ({@code onCreate: Order}, {@code onSchedule: "0 0 * * *"}, + * {@code onMessage: queue:invoices}) interpreted by the process generator. + */ +public class ProcessIntent { + + private String name; + private String description; + private Map trigger = new LinkedHashMap<>(); + private List steps = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map getTrigger() { + return trigger; + } + + public void setTrigger(Map trigger) { + this.trigger = trigger == null ? new LinkedHashMap<>() : trigger; + } + + public List getSteps() { + return steps; + } + + public void setSteps(List steps) { + this.steps = steps == null ? new ArrayList<>() : steps; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java new file mode 100644 index 0000000000..329216072a --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java @@ -0,0 +1,63 @@ +/* + * 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; + +/** + * Relationship between two {@link EntityIntent}s. {@link #kind} is one of {@code oneToMany}, + * {@code manyToOne}, {@code oneToOne}, {@code manyToMany}. + */ +public class RelationIntent { + + private String name; + private String kind; + private String to; + private boolean required; + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getTo() { + return to; + } + + public void setTo(String to) { + this.to = to; + } + + public boolean isRequired() { + return required; + } + + public void setRequired(boolean required) { + this.required = required; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java new file mode 100644 index 0000000000..5b6f3f0ee2 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ReportIntent.java @@ -0,0 +1,76 @@ +/* + * 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.ArrayList; +import java.util.List; + +/** + * Report / aggregation. {@link #source} names the entity to aggregate; {@link #dimensions} are the + * grouping columns, {@link #measures} the aggregation expressions ({@code count(*)}, + * {@code sum(total)}). {@link #filter} is an optional WHERE-style predicate. + */ +public class ReportIntent { + + private String name; + private String source; + private List dimensions = new ArrayList<>(); + private List measures = new ArrayList<>(); + private String filter; + private String description; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public List getDimensions() { + return dimensions; + } + + public void setDimensions(List dimensions) { + this.dimensions = dimensions == null ? new ArrayList<>() : dimensions; + } + + public List getMeasures() { + return measures; + } + + public void setMeasures(List measures) { + this.measures = measures == null ? new ArrayList<>() : measures; + } + + public String getFilter() { + return filter; + } + + public void setFilter(String filter) { + this.filter = filter; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StepIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StepIntent.java new file mode 100644 index 0000000000..f9f59773aa --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StepIntent.java @@ -0,0 +1,50 @@ +/* + * 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.LinkedHashMap; +import java.util.Map; + +/** + * Single step in a {@link ProcessIntent}. {@link #kind} is one of {@code userTask}, + * {@code serviceTask}, {@code decision}, {@code script}, {@code end}. {@link #args} carries + * kind-specific configuration ({@code assignee}, {@code form}, {@code if}, {@code then}, + * {@code call}); the process generator validates per kind. + */ +public class StepIntent { + + private String name; + private String kind; + private Map args = new LinkedHashMap<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public Map getArgs() { + return args; + } + + public void setArgs(Map args) { + this.args = args == null ? new LinkedHashMap<>() : args; + } +} 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 new file mode 100644 index 0000000000..4aaf961509 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -0,0 +1,50 @@ +/* + * 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 org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; + +/** + * Parses the YAML payload of a {@code .intent} file into an {@link IntentModel} tree. SnakeYAML + * loads the document into a generic map; that map is then round-tripped through Gson via + * {@link JsonHelper} so the typed-POJO mapping stays in a single place. + * + *

+ * SafeConstructor blocks the {@code !!type} / {@code !!new} tags - YAML deserialisation of intents + * authored by an LLM or pasted from the web must never become a code-execution surface. + */ +public final class IntentParser { + + private IntentParser() {} + + /** + * Parse the given YAML source into an {@link IntentModel}. + * + * @param yaml the raw YAML content of an {@code .intent} file (may be null or blank) + * @return the typed model, never null - an empty model is returned for blank input + */ + public static IntentModel parse(String yaml) { + if (yaml == null || yaml.isBlank()) { + return new IntentModel(); + } + Yaml loader = new Yaml(new SafeConstructor(new LoaderOptions())); + Object tree = loader.load(yaml); + if (tree == null) { + return new IntentModel(); + } + String json = JsonHelper.toJson(tree); + IntentModel model = JsonHelper.fromJson(json, IntentModel.class); + return model == null ? new IntentModel() : model; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java new file mode 100644 index 0000000000..8878df0e0d --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java @@ -0,0 +1,21 @@ +/* + * 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.repository; + +import org.eclipse.dirigible.components.base.artefact.ArtefactRepository; +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link Intent} artefacts. + */ +@Repository("intentRepository") +public interface IntentRepository extends ArtefactRepository { +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/service/IntentService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/service/IntentService.java new file mode 100644 index 0000000000..0450c95697 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/service/IntentService.java @@ -0,0 +1,28 @@ +/* + * 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.service; + +import org.eclipse.dirigible.components.base.artefact.BaseArtefactService; +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.repository.IntentRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * CRUD service for {@link Intent} artefacts. + */ +@Service +@Transactional +public class IntentService extends BaseArtefactService { + + public IntentService(IntentRepository repository) { + super(repository); + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java new file mode 100644 index 0000000000..2d59bb05ef --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java @@ -0,0 +1,217 @@ +/* + * 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.synchronizer; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.base.artefact.ArtefactLifecycle; +import org.eclipse.dirigible.components.base.artefact.ArtefactPhase; +import org.eclipse.dirigible.components.base.artefact.ArtefactService; +import org.eclipse.dirigible.components.base.artefact.topology.TopologyWrapper; +import org.eclipse.dirigible.components.base.synchronizer.BaseSynchronizer; +import org.eclipse.dirigible.components.base.synchronizer.SynchronizerCallback; +import org.eclipse.dirigible.components.base.synchronizer.SynchronizersOrder; +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.generator.IntentRegenerationService; +import org.eclipse.dirigible.components.intent.service.IntentService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Synchronizer for {@code .intent} files. Sits at the top of {@link SynchronizersOrder} so its + * regenerated {@code gen/} output participates in the next reconciliation cycle ahead of the + * downstream entity / schema / BPMN / form synchronizers. + * + *

+ * The synchronizer itself owns no runtime state - it persists the intent's JSON payload and lets + * {@link IntentRegenerationService} produce / refresh the gen/ files in {@link #finishing()}. + * Lifecycle transitions are pure book-keeping; nothing to start, nothing to stop. + */ +@Component +@Order(SynchronizersOrder.INTENT) +public class IntentSynchronizer extends BaseSynchronizer { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntentSynchronizer.class); + + /** File extension recognized by this synchronizer. */ + public static final String FILE_EXTENSION_INTENT = ".intent"; + + private final IntentService intentService; + private final IntentRegenerationService regenerationService; + private SynchronizerCallback callback; + + /** + * Intents that changed in the current cycle and need their gen/ output refreshed in + * {@link #finishing()}. Keyed by location to coalesce repeated parses of the same file. + */ + private final Map dirty = new LinkedHashMap<>(); + + public IntentSynchronizer(IntentService intentService, IntentRegenerationService regenerationService) { + this.intentService = intentService; + this.regenerationService = regenerationService; + } + + @Override + public boolean isAccepted(String type) { + return Intent.ARTEFACT_TYPE.equals(type); + } + + @Override + public boolean isAccepted(Path file, BasicFileAttributes attrs) { + return file.toString() + .toLowerCase() + .endsWith(FILE_EXTENSION_INTENT); + } + + @Override + protected List parseImpl(String location, byte[] content) throws ParseException { + String yaml = new String(content, StandardCharsets.UTF_8); + Intent intent = new Intent(); + intent.setLocation(location); + intent.setType(Intent.ARTEFACT_TYPE); + intent.setName(deriveName(location)); + intent.setContent(yaml); + intent.updateKey(); + try { + Intent existing = intentService.findByKey(intent.getKey()); + if (existing != null) { + intent.setId(existing.getId()); + } + intent = intentService.save(intent); + } catch (RuntimeException e) { + LOGGER.error("Failed to save intent at [{}]", location, e); + throw new ParseException(e.getMessage(), 0); + } + synchronized (dirty) { + dirty.put(location, intent); + } + return List.of(intent); + } + + @Override + public ArtefactService getService() { + return intentService; + } + + @Override + public List retrieve(String location) { + return intentService.findByLocation(location); + } + + @Override + public void setStatus(Intent artefact, ArtefactLifecycle lifecycle, String error) { + artefact.setLifecycle(lifecycle); + artefact.setError(error); + intentService.save(artefact); + } + + @Override + protected boolean completeImpl(TopologyWrapper wrapper, ArtefactPhase flow) { + Intent intent = wrapper.getArtefact(); + switch (flow) { + case CREATE: + if (ArtefactLifecycle.NEW.equals(intent.getLifecycle())) { + callback.registerState(this, wrapper, ArtefactLifecycle.CREATED); + } + break; + case UPDATE: + if (ArtefactLifecycle.MODIFIED.equals(intent.getLifecycle())) { + callback.registerState(this, wrapper, ArtefactLifecycle.UPDATED); + } + break; + case DELETE: + if (ArtefactLifecycle.CREATED.equals(intent.getLifecycle()) || ArtefactLifecycle.UPDATED.equals(intent.getLifecycle()) + || ArtefactLifecycle.FAILED.equals(intent.getLifecycle())) { + intentService.delete(intent); + callback.registerState(this, wrapper, ArtefactLifecycle.DELETED); + } + break; + case START: + case STOP: + default: + break; + } + return true; + } + + @Override + public void cleanupImpl(Intent intent) { + try { + intentService.delete(intent); + } catch (RuntimeException e) { + LOGGER.error("Failed to delete intent [{}]", intent.getLocation(), e); + callback.addError(e.getMessage()); + callback.registerState(this, intent, ArtefactLifecycle.DELETED, e); + } + } + + @Override + public void setCallback(SynchronizerCallback callback) { + this.callback = callback; + } + + @Override + public String getFileExtension() { + return FILE_EXTENSION_INTENT; + } + + @Override + public String getArtefactType() { + return Intent.ARTEFACT_TYPE; + } + + /** + * Run the regeneration pass for every intent that changed in the current cycle. Files written under + * {@code gen/} become visible to the downstream entity / schema / BPMN / form synchronizers in the + * next reconciliation cycle (the orchestrator's file walk runs once at the start of each cycle, so + * newly-written files inside the same cycle are missed by design). + */ + @Override + public void finishing() { + List snapshot; + synchronized (dirty) { + if (dirty.isEmpty()) { + return; + } + snapshot = new ArrayList<>(dirty.values()); + dirty.clear(); + } + for (Intent intent : snapshot) { + try { + regenerationService.regenerate(intent); + } catch (RuntimeException e) { + LOGGER.error("Failed to regenerate gen/ for intent [{}]", intent.getLocation(), e); + } + } + } + + /** + * Derive a logical name from the file location: strip the path and the {@code .intent} extension, + * returning the base name. Used when the intent JSON itself omits the name field. + */ + private static String deriveName(String location) { + int lastSlash = location.lastIndexOf('/'); + String fileName = lastSlash < 0 ? location : location.substring(lastSlash + 1); + if (fileName.toLowerCase() + .endsWith(FILE_EXTENSION_INTENT)) { + fileName = fileName.substring(0, fileName.length() - FILE_EXTENSION_INTENT.length()); + } + return fileName; + } +} diff --git a/components/group/group-engines/pom.xml b/components/group/group-engines/pom.xml index b48c88239e..1d8b337815 100644 --- a/components/group/group-engines/pom.xml +++ b/components/group/group-engines/pom.xml @@ -97,6 +97,10 @@ org.eclipse.dirigible dirigible-components-engine-listeners + + org.eclipse.dirigible + dirigible-components-engine-intent + org.eclipse.dirigible dirigible-components-engine-websockets diff --git a/components/pom.xml b/components/pom.xml index 38ab87dc32..087f338608 100644 --- a/components/pom.xml +++ b/components/pom.xml @@ -80,6 +80,7 @@ engine/engine-websockets engine/engine-security engine/engine-listeners + engine/engine-intent engine/engine-odata engine/engine-open-telemetry engine/engine-camel diff --git a/modules/pom.xml b/modules/pom.xml index ecd388638c..545e82651e 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -174,6 +174,11 @@ dirigible-components-engine-listeners ${project.version} + + org.eclipse.dirigible + dirigible-components-engine-intent + ${project.version} + org.eclipse.dirigible dirigible-components-engine-template From 9570405aa960d0e2fa334ec44430e0210729ba26 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 09:02:38 +0300 Subject: [PATCH 02/28] Add Intent perspective + first concrete generator (EntityIntentGenerator) Closes the vertical slice for the engine-intent skeleton: - EntityIntentGenerator: first IntentTargetGenerator implementation. Writes /gen/Entity.ts in the canonical decorator form (@Entity / @Table / @Id / @Generated / @Column, @OneToMany / @ManyToOne) so the existing EntitySynchronizer (extension Entity.ts) reconciles the regenerated files into the Hibernate dynamic-map store without further wiring. @Order(100) - leaves room for the upcoming slice generators (schema 200, process 300, ...). Idempotent by construction; no timestamps in the output. - IntentEndpoint at /services/ide/intent/*: GET /projects list projects with an intent GET /projects/{project} parsed IntentModel JSON GET /projects/{project}/source raw YAML POST /projects/{project}/regenerate force a regen pass ADMINISTRATOR / DEVELOPER / OPERATOR. Constructor-injected, package conventions match engine-listeners + ide-messaging-monitoring. - components/ui/perspective-intent: perspective shell (id=intent, order=1020, three-node graph SVG icon themed via currentColor). Default region 'center' with a single view, intent-mermaid. - components/ui/view-intent-mermaid: read-only Mermaid ER renderer backed by IntentEndpoint. Project picker (bk-select), reload, regenerate, source/diagram toggle. Loads mermaid@11 from cdn.jsdelivr.net (matches the unicons CDN pattern in the rest of the IDE). Server returns parsed IntentModel; client converts to an erDiagram spec (PK marker on primary-key fields, cardinality glyphs per relation kind). mermaid.initialize uses securityLevel: 'strict'. - Registered in components/pom.xml (reactor + dependencyManagement) and components/group/group-ide/pom.xml. - CLAUDE.md updated: notes EntityIntentGenerator as the worked example, documents the perspective + view layout, trims the follow-up list to what is genuinely still pending (Claude bridge, remaining slice generators, /custom/ escape-hatch, read-only gen/ Monaco model, reverse-engineer, same-cycle visibility, schema validation). Build verified: quick-build install + formatter:validate + release-profile javadoc gate all clean for the touched modules. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 22 +- .../intent/endpoint/IntentEndpoint.java | 136 ++++++++ .../entity/EntityIntentGenerator.java | 291 ++++++++++++++++++ components/group/group-ide/pom.xml | 8 + components/pom.xml | 12 + components/ui/perspective-intent/pom.xml | 21 ++ .../perspective-intent/configs/intent.js | 21 ++ .../configs/perspective-menu.js | 19 ++ .../extensions/perspective-menu.extension | 5 + .../extensions/perspective.extension | 5 + .../perspective-intent/images/intent.svg | 9 + .../dirigible/perspective-intent/index.html | 41 +++ .../dirigible/perspective-intent/project.json | 5 + components/ui/view-intent-mermaid/pom.xml | 21 ++ .../configs/intent-mermaid-view.js | 22 ++ .../extensions/intent-mermaid.extension | 5 + .../view-intent-mermaid/intent-mermaid.html | 207 +++++++++++++ .../view-intent-mermaid/project.json | 5 + 18 files changed, 848 insertions(+), 7 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java create mode 100644 components/ui/perspective-intent/pom.xml create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/intent.js create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html create mode 100644 components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json create mode 100644 components/ui/view-intent-mermaid/pom.xml create mode 100644 components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js create mode 100644 components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension create mode 100644 components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html create mode 100644 components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 184a94d7d7..fe33fd23e5 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -58,10 +58,18 @@ components/engine/engine-intent/ │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + IRepository │ └── IntentRegenerationService.java # collects every SPI bean and runs them in @Order - └── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() + ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() + └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` -No generator implementations live in this module yet - only the SPI. Concrete generators (entity → `gen/.entity` + `gen/.dsm`, process → `gen/.bpmn`, form → `gen/.form`, report → `gen/.report`, permission → `gen/.roles` + `gen/.access`, controller → `gen/.controller.ts`) belong in follow-up modules or sibling generator packages that depend on this one plus the relevant target artefact module. They are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order`. +The IDE perspective lives in two sibling UI modules: + +- `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. +- `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. + +One concrete generator currently lives in-module: `generator/entity/EntityIntentGenerator` writes `gen/Entity.ts` per intent entity, in the decorator format that `EntitySynchronizer` (extension `Entity.ts`) picks up. It is the worked example - any further slice generators follow the same pattern. + +The remaining concrete generators (process → `gen/.bpmn`, form → `gen/.form`, report → `gen/.report`, permission → `gen/.roles` + `gen/.access`, controller → `gen/.controller.ts`) belong in follow-up modules or sibling generator packages that depend on this one plus the relevant target artefact module. They are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order` (`EntityIntentGenerator` sits at 100, so leave room: schema 200, process 300, form 400, etc.). ## Wiring @@ -158,12 +166,12 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci - **Don't reuse the existing modelers for intent projects.** That would re-expose `gen/` as an authoring surface and undo the whole point. - **Don't read env vars or system properties directly** - go through `DirigibleConfig` per the platform-wide rule. -## Follow-ups not in this skeleton +## Follow-ups -- Concrete `IntentTargetGenerator` implementations per slice (entities, processes, forms, reports, permissions, controllers). -- IDE perspective with Mermaid renderer + Claude chat + patch preview + accept/reject. Lives in `components/ui/perspective-intent` once the generators land. -- Read-only Monaco model for paths under `**/gen/**`. -- `/custom/` escape-hatch directory + per-slice hook points in the generators. +- Remaining concrete generators: schema (DSM), process (BPMN), form, report, permission (roles + access), controller (TS or Java). +- Claude chat + patch-preview in the perspective. Needs a separate LLM bridge module (Anthropic API key via `DirigibleConfig`, request shaping, structured-patch responses, accept / reject flow). Out of scope for this PR. +- Read-only Monaco model for paths under `**/gen/**` so the IDE marks them not-for-editing. +- `/custom/` escape-hatch directory + per-slice hook points in the generators (the generators must learn to preserve `/custom/` files alongside their gen output). - `reverse-engineer intent` command for migrating classic projects. - Same-cycle visibility (open design question above). - Schema validation on parse (currently the parser accepts anything SnakeYAML + Gson can map). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java new file mode 100644 index 0000000000..ca7c3e3239 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java @@ -0,0 +1,136 @@ +/* + * 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.endpoint; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.annotation.security.RolesAllowed; + +import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint; +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.generator.IntentRegenerationService; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.components.intent.service.IntentService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * REST surface for the intent perspective. Reads the persisted {@link Intent} artefacts and the + * derived {@link IntentModel} tree. Mutating endpoints are limited to manual regeneration; the + * intent itself is authored through the IDE workspace (or a future Claude bridge) and reconciled + * via {@code IntentSynchronizer}. + */ +@RestController +@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_IDE + "intent") +@RolesAllowed({"ADMINISTRATOR", "DEVELOPER", "OPERATOR"}) +public class IntentEndpoint { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntentEndpoint.class); + + private final IntentService intentService; + private final IntentRegenerationService regenerationService; + + public IntentEndpoint(IntentService intentService, IntentRegenerationService regenerationService) { + this.intentService = intentService; + this.regenerationService = regenerationService; + } + + /** + * List every project that has at least one persisted {@link Intent}. Project name is the first path + * segment of the artefact location. + */ + @GetMapping(value = "/projects", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> projects() { + Set projects = new LinkedHashSet<>(); + for (Intent intent : intentService.getAll()) { + String project = projectOf(intent.getLocation()); + if (project != null) { + projects.add(project); + } + } + return ResponseEntity.ok(projects.stream() + .sorted() + .collect(Collectors.toList())); + } + + /** + * Return the parsed intent model for the given project. 404 if the project has no {@code .intent} + * on file. + */ + @GetMapping(value = "/projects/{project}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity intent(@PathVariable("project") String project) { + Intent intent = findIntent(project); + return ResponseEntity.ok(IntentParser.parse(intent.getContent())); + } + + /** + * Return the raw YAML source of the intent file. Suitable for read-only display in the perspective; + * the workspace's normal editor is the authoring path. + */ + @GetMapping(value = "/projects/{project}/source", produces = "text/yaml; charset=UTF-8") + public ResponseEntity source(@PathVariable("project") String project) { + Intent intent = findIntent(project); + return ResponseEntity.ok(intent.getContent() == null ? "" : intent.getContent()); + } + + /** + * Force a regeneration pass for the given project's intent. Useful from the perspective's + * Regenerate button: avoids waiting for the next synchronizer cycle. + */ + @PostMapping(value = "/projects/{project}/regenerate", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> regenerate(@PathVariable("project") String project) { + Intent intent = findIntent(project); + try { + regenerationService.regenerate(intent); + return ResponseEntity.ok(Map.of("project", project, "status", "regenerated")); + } catch (RuntimeException e) { + LOGGER.error("Regeneration failed for project [{}]", project, e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Regeneration failed: " + e.getMessage(), e); + } + } + + private Intent findIntent(String project) { + for (Intent intent : intentService.getAll()) { + if (project.equals(projectOf(intent.getLocation()))) { + return intent; + } + } + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No intent for project [" + project + "]"); + } + + /** + * First non-empty path segment of an artefact location. + */ + private static String projectOf(String location) { + if (location == null) { + return null; + } + int start = location.startsWith("/") ? 1 : 0; + int end = location.indexOf('/', start); + if (end < 0) { + return null; + } + return location.substring(start, end); + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java new file mode 100644 index 0000000000..32405ef65d --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java @@ -0,0 +1,291 @@ +/* + * 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.entity; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits a {@code Entity.ts} decorator-driven TypeScript file under {@code gen/} for + * every entity declared in an intent. The output matches the canonical Dirigible entity shape (see + * {@code tests/tests-integrations/src/main/resources/typescript/CustomerEntity.ts}) so that the + * existing {@code EntitySynchronizer} (file extension {@code Entity.ts}) picks the files up on the + * next reconciliation cycle and projects them into the Hibernate dynamic-map store. + * + *

+ * Idempotent: identical input always produces byte-identical output. Generators must not introduce + * timestamps or stable-but-version-sensitive headers. + */ +@Component +@Order(100) +public class EntityIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(EntityIntentGenerator.class); + + /** SQL column-name length cap for the auto-derived names. Keeps Oracle / older RDBMS happy. */ + private static final int MAX_COLUMN_NAME_LENGTH = 30; + + @Override + public String name() { + return "entity"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getEntities() + .isEmpty()) { + return; + } + IRepository repository = context.getRepository(); + String genRoot = context.getGenRoot(); + Set seenFqns = new HashSet<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() == null || entity.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed entity in intent [{}]", context.getIntent() + .getName()); + continue; + } + String fileName = entity.getName() + "Entity.ts"; + String path = genRoot + "/" + fileName; + if (!seenFqns.add(path)) { + LOGGER.warn("Duplicate entity [{}] in intent [{}] - keeping the first occurrence", entity.getName(), context.getIntent() + .getName()); + continue; + } + String source = render(entity); + writeResource(repository, path, source); + } + } + + private static String render(EntityIntent entity) { + StringBuilder sb = new StringBuilder(); + sb.append("// Generated from .intent - do not edit by hand. Edit the .intent and republish.\n"); + sb.append("@Entity(\"") + .append(entity.getName()) + .append("\")\n"); + sb.append("@Table(\"") + .append(toUpperSnake(entity.getName())) + .append("\")\n"); + sb.append("export class ") + .append(entity.getName()) + .append(" {\n"); + for (int i = 0; i < entity.getFields() + .size(); i++) { + FieldIntent field = entity.getFields() + .get(i); + appendField(sb, entity, field); + if (i < entity.getFields() + .size() + - 1 + || !entity.getRelations() + .isEmpty()) { + sb.append('\n'); + } + } + for (int i = 0; i < entity.getRelations() + .size(); i++) { + RelationIntent relation = entity.getRelations() + .get(i); + appendRelation(sb, relation); + if (i < entity.getRelations() + .size() + - 1) { + sb.append('\n'); + } + } + sb.append("}\n"); + return sb.toString(); + } + + private static void appendField(StringBuilder sb, EntityIntent entity, FieldIntent field) { + if (field.getName() == null || field.getName() + .isBlank()) { + return; + } + if (field.isPrimaryKey()) { + sb.append(" @Id()\n"); + } + if (field.isGenerated()) { + sb.append(" @Generated(\"sequence\")\n"); + } + sb.append(" @Column({ name: \"") + .append(toColumnName(entity.getName(), field.getName())) + .append("\", type: \"") + .append(mapType(field.getType())) + .append("\""); + if (field.getLength() != null && field.getLength() > 0) { + sb.append(", length: ") + .append(field.getLength()); + } + if (field.isRequired() || field.isPrimaryKey()) { + sb.append(", nullable: false"); + } + if (field.getDefaultValue() != null && !field.getDefaultValue() + .isBlank()) { + sb.append(", defaultValue: \"") + .append(field.getDefaultValue()) + .append("\""); + } + sb.append(" })\n"); + sb.append(" public ") + .append(field.getName()) + .append(": ") + .append(mapTsType(field.getType())) + .append(";\n"); + } + + private static void appendRelation(StringBuilder sb, RelationIntent relation) { + if (relation.getName() == null || relation.getTo() == null) { + return; + } + String kind = relation.getKind() == null ? "manyToOne" : relation.getKind(); + switch (kind) { + case "oneToMany": + sb.append(" @OneToMany(() => ") + .append(relation.getTo()) + .append(", { joinColumn: \"") + .append(toColumnName(relation.getTo(), "id")) + .append("\" })\n"); + sb.append(" public ") + .append(relation.getName()) + .append(": ") + .append(relation.getTo()) + .append("[];\n"); + break; + case "manyToOne": + default: + sb.append(" @ManyToOne(() => ") + .append(relation.getTo()) + .append(", { joinColumn: \"") + .append(toColumnName(relation.getTo(), "id")) + .append("\" })\n"); + sb.append(" public ") + .append(relation.getName()) + .append(": ") + .append(relation.getTo()) + .append(";\n"); + break; + } + } + + /** + * Map an intent logical field type to a Dirigible SDK column type string. Unknown types fall back + * to {@code string} - generators must never crash on a typo; the engine logs a warning instead. + */ + private static String mapType(String type) { + if (type == null) { + return "string"; + } + switch (type.toLowerCase(Locale.ROOT)) { + case "integer": + case "int": + return "integer"; + case "long": + return "long"; + case "decimal": + case "double": + return "double"; + case "boolean": + return "boolean"; + case "date": + return "date"; + case "uuid": + return "string"; + case "text": + return "string"; + case "string": + default: + return "string"; + } + } + + /** + * Map the intent logical type to the TypeScript property type. + */ + private static String mapTsType(String type) { + if (type == null) { + return "string"; + } + switch (type.toLowerCase(Locale.ROOT)) { + case "integer": + case "int": + case "long": + case "decimal": + case "double": + return "number"; + case "boolean": + return "boolean"; + case "date": + return "Date"; + case "uuid": + case "text": + case "string": + default: + return "string"; + } + } + + /** + * Convert a camelCase or PascalCase identifier to UPPER_SNAKE_CASE for the column name. Capped at + * {@link #MAX_COLUMN_NAME_LENGTH} characters. + */ + private static String toUpperSnake(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(name.length() + 8); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + out.append('_'); + } + out.append(Character.toUpperCase(c)); + } + return out.toString(); + } + + /** + * Produce a stable column name of the form {@code _}, snake-cased and capped. + */ + private static String toColumnName(String entityName, String fieldName) { + String raw = toUpperSnake(entityName) + "_" + toUpperSnake(fieldName); + if (raw.length() <= MAX_COLUMN_NAME_LENGTH) { + return raw; + } + return raw.substring(0, MAX_COLUMN_NAME_LENGTH); + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} diff --git a/components/group/group-ide/pom.xml b/components/group/group-ide/pom.xml index 4530e4e2e9..cbb7a2b2b6 100644 --- a/components/group/group-ide/pom.xml +++ b/components/group/group-ide/pom.xml @@ -98,6 +98,10 @@ org.eclipse.dirigible dirigible-components-ui-perspective-messaging + + org.eclipse.dirigible + dirigible-components-ui-perspective-intent + org.eclipse.dirigible dirigible-components-ui-perspective-jobs @@ -202,6 +206,10 @@ org.eclipse.dirigible dirigible-components-ui-view-messaging-browser + + org.eclipse.dirigible + dirigible-components-ui-view-intent-mermaid + org.eclipse.dirigible dirigible-components-ui-view-data-structures diff --git a/components/pom.xml b/components/pom.xml index 087f338608..9e74df231a 100644 --- a/components/pom.xml +++ b/components/pom.xml @@ -149,6 +149,7 @@ ui/perspective-operations ui/perspective-monitoring ui/perspective-messaging + ui/perspective-intent ui/perspective-jobs ui/perspective-security ui/perspective-processes @@ -177,6 +178,7 @@ ui/view-monitoring-metrics ui/view-messaging-destinations ui/view-messaging-browser + ui/view-intent-mermaid ui/view-configurations ui/view-data-structures ui/view-extensions @@ -882,6 +884,11 @@ dirigible-components-ui-perspective-messaging ${project.version} + + org.eclipse.dirigible + dirigible-components-ui-perspective-intent + ${project.version} + org.eclipse.dirigible dirigible-components-ui-perspective-jobs @@ -1019,6 +1026,11 @@ dirigible-components-ui-view-messaging-browser ${project.version} + + org.eclipse.dirigible + dirigible-components-ui-view-intent-mermaid + ${project.version} + org.eclipse.dirigible dirigible-components-ui-view-configurations diff --git a/components/ui/perspective-intent/pom.xml b/components/ui/perspective-intent/pom.xml new file mode 100644 index 0000000000..7e7a734c16 --- /dev/null +++ b/components/ui/perspective-intent/pom.xml @@ -0,0 +1,21 @@ + + 4.0.0 + + + org.eclipse.dirigible + dirigible-components-parent + 14.0.0-SNAPSHOT + ../../pom.xml + + + Components - UI - Intent - Perspective + dirigible-components-ui-perspective-intent + jar + + + ../../../licensing-header.txt + ../../../ + + + diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/intent.js b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/intent.js new file mode 100644 index 0000000000..0dded0b714 --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/intent.js @@ -0,0 +1,21 @@ +/* + * 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 + */ +const perspectiveData = { + id: 'intent', + label: 'Intent', + path: '/services/web/perspective-intent/index.html', + order: 1020, + icon: '/services/web/perspective-intent/images/intent.svg', +}; +if (typeof exports !== 'undefined') { + exports.getPerspective = () => perspectiveData; +} diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js new file mode 100644 index 0000000000..290c26a9dd --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js @@ -0,0 +1,19 @@ +/* + * 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 + */ +exports.getMenu = () => ({ + perspectiveId: 'intent', + include: { + window: true, + help: true, + }, + items: [] +}); diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension new file mode 100644 index 0000000000..b50268033d --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension @@ -0,0 +1,5 @@ +{ + "module": "perspective-intent/configs/perspective-menu.js", + "extensionPoint": "platform-menus", + "description": "Intent perspective menu" +} diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension new file mode 100644 index 0000000000..940fca9fba --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension @@ -0,0 +1,5 @@ +{ + "module": "perspective-intent/configs/intent.js", + "extensionPoint": "platform-perspectives", + "description": "Intent Perspective" +} diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg new file mode 100644 index 0000000000..73d841b413 --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html new file mode 100644 index 0000000000..58d1dd4af9 --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json new file mode 100644 index 0000000000..2db51c640b --- /dev/null +++ b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json @@ -0,0 +1,5 @@ +{ + "guid": "perspective-intent", + "dependencies": [], + "actions": [] +} diff --git a/components/ui/view-intent-mermaid/pom.xml b/components/ui/view-intent-mermaid/pom.xml new file mode 100644 index 0000000000..c659007c07 --- /dev/null +++ b/components/ui/view-intent-mermaid/pom.xml @@ -0,0 +1,21 @@ + + 4.0.0 + + + org.eclipse.dirigible + dirigible-components-parent + 14.0.0-SNAPSHOT + ../../pom.xml + + + Components - UI - Intent - Mermaid View + dirigible-components-ui-view-intent-mermaid + jar + + + ../../../licensing-header.txt + ../../../ + + + diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js new file mode 100644 index 0000000000..955bbbae8e --- /dev/null +++ b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js @@ -0,0 +1,22 @@ +/* + * 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 + */ +const viewData = { + id: 'intent-mermaid', + region: 'center', + label: 'Diagram', + lazyLoad: true, + autoFocusTab: false, + path: '/services/web/view-intent-mermaid/intent-mermaid.html' +}; +if (typeof exports !== 'undefined') { + exports.getView = () => viewData; +} diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension new file mode 100644 index 0000000000..a444b764ac --- /dev/null +++ b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension @@ -0,0 +1,5 @@ +{ + "module": "view-intent-mermaid/configs/intent-mermaid-view.js", + "extensionPoint": "platform-views", + "description": "Intent Mermaid View" +} diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html new file mode 100644 index 0000000000..62dd99518d --- /dev/null +++ b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + Intent + + + + + + + + + + + + + No intent projects + Drop an app.intent YAML at a project root and republish. + + + + Unable to load intent + {{errorMessage}} + + +

+
+
{{ sourceYaml }}
+
+ + + + + + diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json new file mode 100644 index 0000000000..f6c263bce8 --- /dev/null +++ b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json @@ -0,0 +1,5 @@ +{ + "guid": "view-intent-mermaid", + "dependencies": [], + "actions": [] +} From 63f9e6c4c81c71952a78d9c3f572c66e2618ed9f Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 09:10:15 +0300 Subject: [PATCH 03/28] Fix IntentRepository context startup: explicit setRunningToAll query Every concrete ArtefactRepository must override ArtefactRepository.setRunningToAll with an explicit @Query, otherwise Spring Data tries to derive a query from the method name and the context fails to start with: No property 'setRunningToAll' found for type 'Intent' Every other artefact repository (Listener, Table, View, Schema, Entity, Csvim, DataSource, OpenAPI, Camel, ExtensionPoint, Extension, ...) carries the same override. The skeleton missed it because IntentRepository was modelled on the NoRepositoryBean SPI alone, not on a working repo. Same shape as ListenerRepository. This unblocks the integration-test jobs that were failing in the open PR (#6017) - they all blew up at Spring context bootstrap, not in actual test code. Co-Authored-By: Claude Opus 4.7 --- .../intent/repository/IntentRepository.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java index 8878df0e0d..4f3fed92e3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/repository/IntentRepository.java @@ -11,11 +11,27 @@ import org.eclipse.dirigible.components.base.artefact.ArtefactRepository; import org.eclipse.dirigible.components.intent.domain.Intent; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; /** * Spring Data JPA repository for {@link Intent} artefacts. + * + *

+ * The explicit {@code @Query} on {@link #setRunningToAll(boolean)} is mandatory: every concrete + * artefact repository must override the parent's abstract declaration, otherwise Spring Data tries + * to derive a query from the method name {@code setRunningToAll} and fails on context startup with + * "No property 'setRunningToAll' found for type 'Intent'". */ @Repository("intentRepository") public interface IntentRepository extends ArtefactRepository { + + @Override + @Modifying + @Transactional + @Query(value = "UPDATE Intent SET running = :running") + void setRunningToAll(@Param("running") boolean running); } From 8b06556645db38088645ee35bae329748e680c8e Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 10:47:03 +0300 Subject: [PATCH 04/28] Correct intent altitude: generate model files, not code Replaces the wrong-direction EntityIntentGenerator with the correct EdmIntentGenerator and locks in the corrected architecture in the module CLAUDE.md so future sessions don't repeat the mistake. The contract: app.intent (YAML) -> Intent generators (this engine) gen/.edm + gen/.model (entities) gen/.bpmn (processes - TODO) gen/

.form (forms - TODO) gen/.report (reports - TODO) gen/.roles + gen/.access (permissions - TODO) -> Existing Dirigible template engine TS / HTML / Java / SQL under gen//... Intent generators stop at the model layer. They do NOT emit Entity.ts, Controller.ts, *.java or any other code-shaped output - that artefact belongs to the existing "Generate from EDM/Schema/BPMN" template flow. Changes: - Remove EntityIntentGenerator.java (was emitting decorator TS at the wrong altitude; committed in 9570405aa9, now reverted). - Add EdmIntentGenerator (@Order 200) writing gen/.edm (XML) and gen/.model (JSON twin) from the entities + relations in the intent. Both files come from a single typed map so they can never drift. Conservative defaults for icons, menu keys, layout type, perspective metadata, widget types - derived from the entity / field name so the produced .edm is openable and editable as-is. - Add IntentGenerationContext.getProjectName() so generators can derive project-scoped paths without duplicating the parsing logic. - Add IntentParser structural validation: duplicate names, dangling relation / form-entity / report-source targets, unknown field / relation / step kinds, multiple primary keys per entity. All issues surface in one IntentValidationException with the complete list. - Rewrite CLAUDE.md: * New "Two-stage architecture" section at the top * New "Wrong turns we already made" section documenting the EntityIntentGenerator misstep and the related PermissionIntent one so they aren't repeated * "Concrete agreements": new top bullet restricting output extensions to the model layer only * "Things to not do": new bullets banning code-shaped output and template-engine path references * Layout / generator table / follow-ups all aligned with the new altitude quick-build + formatter:validate + release-profile javadoc gate all clean on the touched modules. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 66 ++- .../generator/IntentGenerationContext.java | 16 + .../generator/edm/EdmIntentGenerator.java | 447 ++++++++++++++++++ .../entity/EntityIntentGenerator.java | 291 ------------ .../intent/parser/IntentParser.java | 177 ++++++- .../parser/IntentValidationException.java | 46 ++ 6 files changed, 742 insertions(+), 301 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java delete mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentValidationException.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index fe33fd23e5..0f5570cdf8 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -1,9 +1,27 @@ # engine-intent -A single `.intent` YAML file at a project root becomes the source of truth for every other artefact in that project. EDM, DSM, BPMN, forms, reports, generated TS / Java are all `gen/` artefacts owned by this engine - developers must not edit them. This is one altitude higher than the existing pattern (where the standard models were the source and HTML / SQL / ORM mappings were the gen output): here the standard models themselves are gen. +A single `.intent` YAML file at a project root becomes the source of truth for every other authoring artefact in that project. The intent layer is **one altitude above** the existing model files: where Dirigible used to have hand-authored `.edm` / `.bpmn` / `.form` / `.report` / `.roles` / `.access` / `.csvim` and code-gen them to TS / HTML / Java / SQL on demand, the intent layer authors the **`.edm` / `.bpmn` / ... model files themselves** from one YAML. The whole feature lives in `org.eclipse.dirigible.components.intent.*`. +## Two-stage architecture - **read this first** + +``` +app.intent (YAML, author-driven by Claude / human / structured panel) + ↓ Intent generators (this engine) +gen/.edm + gen/.model ← entities + relations + UI metadata +gen/.bpmn ← processes +gen/.form ← forms +gen/.report ← reports +gen/.roles + gen/.access ← permissions +gen/.csvim + gen/.csv ← seed data (future) +gen/.dsm + gen/.schema ← low-level data structures (future) + ↓ Existing Dirigible template engine + per-artefact synchronizers +[Hibernate-mapped tables, generated TS / HTML / Java / SQL artefacts under gen//...] +``` + +Intent generators stop at the **model file**. They never emit `Entity.ts`, `Controller.ts`, `Repository.ts`, HTML, Java, or SQL directly - those come from the IDE's existing "Generate from EDM / Schema / BPMN" templates, fed by the model files this engine wrote. That contract is non-negotiable; see "Wrong turns we already made" below. + ## Design context (what we agreed) Distilled from the chat that produced the initial scaffold. Read this BEFORE designing additional pieces - half of these decisions are not obvious from the code. @@ -24,6 +42,7 @@ The dream is "no code, no modelling - just prompt": user describes what they wan ### Concrete agreements +- **Intent generators target the model layer ONLY.** Output extensions are restricted to `.edm` / `.model` / `.bpmn` / `.form` / `.report` / `.roles` / `.access` / `.dsm` / `.schema` / `.table` / `.view` / `.csvim` / `.csv`. Anything code-shaped (`Entity.ts`, `Controller.ts`, `Repository.ts`, `*.java`, `*.html`, `*.sql`) is the **template engine's** output and must not appear in any intent generator. If you find yourself emitting code, you are at the wrong altitude. - **YAML, not JSON.** Optimised for human authoring (comments, multi-line strings, no quote noise, friendlier LLM diffs). Parsed via SnakeYAML's `SafeConstructor` (already on the classpath transitively via Spring Boot) then round-tripped through Gson to land in the typed POJOs - one mapping path for both surfaces. - **Safe YAML loading is non-negotiable.** `IntentParser` constructs SnakeYAML with `SafeConstructor`, which blocks `!!type` / `!!new` tags. Intents arrive from LLM output and human paste; YAML deserialisation must never become a code-execution surface. Do not swap to `Constructor` for "ergonomics". - **One `.intent` file per project**, at the project root. There is no plan to support multiple intents per project - the whole model lives in one place so the LLM has the whole picture to diff against. (Re-evaluate if intents grow past ~2000 lines in practice; until then, one file.) @@ -32,6 +51,15 @@ The dream is "no code, no modelling - just prompt": user describes what they wan - **Mermaid renders the intent for visualisation**, read-only. We do NOT build a Mermaid round-trip editor (it is a poor authoring surface). Editing is via the LLM prompt + structured panel; the existing modelers are NOT re-used for intent projects (they would let developers edit gen/ in disguise). - **Run-once-fix-it via Claude.** When something can't be expressed, the answer is to extend `.intent` (add a field to the schema, add a generator that consumes it), not to leak into gen/. +### Wrong turns we already made + +These mistakes have been made and reverted. They are documented here so they are not made again. + +1. **EntityIntentGenerator that wrote `gen/Entity.ts` directly.** This was at the wrong altitude - it tried to emit the `@Entity()` / `@Table()` / `@Column()` decorator-driven TS file that the platform's `EntitySynchronizer` (extension `Entity.ts`) consumes. That artefact is itself the output of "Generate from EDM" in the IDE; intent should emit the `.edm` instead and let the existing pipeline produce the TS. The generator was committed once (commit `9570405aa9`) and later deleted - do not bring it back. The replacement is [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) at `@Order(200)`. +2. **PermissionIntentGenerator that wrote `.access` constraints with URLs targeting the (also-wrong) generated `Controller.ts` paths.** Same mistake one altitude up: the access URLs were `/services/ts//gen/Controller.ts/*`, which assumed the missing TS controllers existed. The right output for permissions is the same `.roles` + `.access` artefacts but with paths reflecting whatever the EDM template emits, OR (preferred) lean on the `.edm` entity's own `generateDefaultRoles="true"` flag and let the template produce roles + access in lockstep with the generated UI. Not yet implemented; see the follow-up list. + +The general rule the above two violated: **intent generators must never reference paths or routes that belong to the template engine's output**, because the intent layer should be agnostic about which template is selected. + ## Module layout ``` @@ -53,11 +81,14 @@ components/engine/engine-intent/ │ ├── FormIntent.java │ ├── ReportIntent.java │ └── PermissionIntent.java - ├── parser/IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (Gson) + ├── parser/ + │ ├── IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (Gson) + structural validation + │ └── IntentValidationException.java # collects every structural issue in one shot ├── generator/ │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) - │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + IRepository - │ └── IntentRegenerationService.java # collects every SPI bean and runs them in @Order + │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + projectName + IRepository + │ ├── IntentRegenerationService.java # collects every SPI bean and runs them in @Order + │ └── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -67,9 +98,21 @@ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. - `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. -One concrete generator currently lives in-module: `generator/entity/EntityIntentGenerator` writes `gen/Entity.ts` per intent entity, in the decorator format that `EntitySynchronizer` (extension `Entity.ts`) picks up. It is the worked example - any further slice generators follow the same pattern. +One concrete generator currently lives in-module: [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. It is the worked example - additional slice generators follow the same pattern. + +The remaining generators each map one intent block to one model-layer extension: -The remaining concrete generators (process → `gen/.bpmn`, form → `gen/.form`, report → `gen/.report`, permission → `gen/.roles` + `gen/.access`, controller → `gen/.controller.ts`) belong in follow-up modules or sibling generator packages that depend on this one plus the relevant target artefact module. They are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order` (`EntityIntentGenerator` sits at 100, so leave room: schema 200, process 300, form 400, etc.). +| Intent block | Output | Spring `@Order` (suggested) | +|---|---|---| +| `entities` | `gen/.edm` + `gen/.model` | 200 (done) | +| `processes[]` | `gen/.bpmn` (one per process) | 300 | +| `forms[]` | `gen/.form` | 400 | +| `reports[]` | `gen/.report` | 500 | +| `permissions` | `gen/.roles` + `gen/.access` | 600 | +| (future) `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 | +| (future) low-level `schemas[]` | `gen/.dsm` + `gen/.schema` | 250 | + +All implementations are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order`. Leave gaps of 100 so future generators can slot between. ## Wiring @@ -159,19 +202,26 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci ## Things to not do +- **Don't emit code-shaped files from any intent generator.** No `*.ts`, `*.java`, `*.html`, `*.sql`, `*.css`. Output extensions are restricted to the model layer (`.edm` / `.model` / `.bpmn` / `.form` / `.report` / `.roles` / `.access` / `.dsm` / `.schema` / `.table` / `.view` / `.csvim` / `.csv`). The existing template engine produces code; the intent layer produces models. - **Don't make intent multi-tenant.** Authoring is single-tenant; generated artefacts handle their own tenancy. - **Don't let intent rewrite or sort itself.** Diff stability matters - the LLM has to produce minimal patches, which only works if the on-disk shape is stable. No auto-formatting, no field reordering. - **Don't generate outside `gen/`.** The synchronizer relies on this to scrub stale gen/ files between cycles without risking developer-authored files. `IntentGenerationContext.getGenRoot()` is the only path generators write to. +- **Don't reference template-engine output paths.** Intent generators must be ignorant of which downstream template the user will run. The `.access` constraints must not name `gen/Controller.ts` paths; either use the EDM's own `generateDefaultRoles="true"` flag (preferred) or emit role / path tokens the template engine resolves itself. - **Don't add a Mermaid editor.** Mermaid is for visualisation. Authoring is prompt + structured panel. - **Don't reuse the existing modelers for intent projects.** That would re-expose `gen/` as an authoring surface and undo the whole point. - **Don't read env vars or system properties directly** - go through `DirigibleConfig` per the platform-wide rule. ## Follow-ups -- Remaining concrete generators: schema (DSM), process (BPMN), form, report, permission (roles + access), controller (TS or Java). +- Remaining model-layer generators: BPMN (process), `.form`, `.report`, `.roles` + `.access` (permissions). DSM / schema / table / view / csvim / csv are lower-priority because the EDM the first generator writes already covers the same surface implicitly. +- Trigger the "Generate from EDM" template programmatically on intent change so the developer sees the full app, not just the model files. Today the user has to open the EDM editor and click Generate manually. - Claude chat + patch-preview in the perspective. Needs a separate LLM bridge module (Anthropic API key via `DirigibleConfig`, request shaping, structured-patch responses, accept / reject flow). Out of scope for this PR. - Read-only Monaco model for paths under `**/gen/**` so the IDE marks them not-for-editing. - `/custom/` escape-hatch directory + per-slice hook points in the generators (the generators must learn to preserve `/custom/` files alongside their gen output). - `reverse-engineer intent` command for migrating classic projects. - Same-cycle visibility (open design question above). -- Schema validation on parse (currently the parser accepts anything SnakeYAML + Gson can map). +- Stale-file cleanup: when an entity is removed from intent, its slot in the `.edm` should disappear too. `EdmIntentGenerator` regenerates the whole `.edm` from scratch so removal is automatic for entities; if we ever shard the EDM to one file per entity, this becomes non-trivial. + +**Done:** + +- Structural validation on parse: duplicate names, dangling relation / form / report targets, unknown field / relation / step kinds. Surfaced via `IntentValidationException` with the complete list of issues in one error message. 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 cb2e884818..87f0102eba 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 @@ -43,6 +43,22 @@ public IntentGenerationContext(Intent intent, IntentModel model, String projectR this.repository = repository; } + /** + * Project name derived from the project root. The intent location is + * {@code //.../app.intent} inside the repository so the project name is the first + * non-empty path segment of {@link #projectRoot}. + * + * @return the project name, never null but possibly empty for malformed roots + */ + public String getProjectName() { + if (projectRoot == null || projectRoot.isEmpty()) { + return ""; + } + int start = projectRoot.startsWith("/") ? 1 : 0; + int next = projectRoot.indexOf('/', start); + return next < 0 ? projectRoot.substring(start) : projectRoot.substring(start, next); + } + public Intent getIntent() { return intent; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java new file mode 100644 index 0000000000..d0c7a5daa3 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -0,0 +1,447 @@ +/* + * 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.edm; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits {@code gen/.edm} (XML) and its JSON twin {@code gen/.model} for every + * intent that declares one or more entities. The pair is the canonical entity-data-model file + * consumed by the EDM editor in the IDE and by the downstream "Generate from EDM" template engine, + * which turns the model into UI / Java / SQL artefacts in a second step. The intent layer never + * emits those second-stage artefacts itself - that contract belongs to the existing template + * generators. + * + *

+ * The intent JSON is intentionally narrower than the EDM XML attribute surface. Everything the EDM + * editor expects but the intent omits (icons, menu keys, layout type, perspective metadata, widget + * type) is filled with conservative defaults derived from the entity / field name: + *

    + *
  • {@code dataName} = upper-snake of the name
  • + *
  • {@code icon} / {@code perspectiveIcon} = + * {@code /services/web/resources/unicons/file.svg}
  • + *
  • {@code type} = {@code PRIMARY}, or {@code DEPENDENT} if another entity owns it via a + * {@code manyToOne}
  • + *
  • {@code layoutType} = {@code MANAGE_MASTER} / {@code MANAGE_DETAILS} matching the above
  • + *
  • {@code widgetType} = derived from field type (TEXTBOX / NUMBER / DATE / CHECKBOX); FK + * properties get DROPDOWN
  • + *
+ * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(200) +public class EdmIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(EdmIntentGenerator.class); + + private static final String DEFAULT_ICON = "/services/web/resources/unicons/file.svg"; + + @Override + public String name() { + return "edm"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getEntities() + .isEmpty()) { + return; + } + Map root = buildModel(model); + String baseName = baseName(context); + String genRoot = context.getGenRoot(); + IRepository repo = context.getRepository(); + writeResource(repo, genRoot + "/" + baseName + ".model", JsonHelper.toJson(root)); + writeResource(repo, genRoot + "/" + baseName + ".edm", renderEdmXml(root)); + } + + /** + * Build the typed map that mirrors the canonical {@code .model} JSON shape. Both the JSON + * serializer and the XML renderer consume this same tree, so the two on-disk formats can never + * drift. + */ + private static Map buildModel(IntentModel model) { + List entities = model.getEntities(); + Set dependentEntities = computeDependents(entities); + + List> entityList = new ArrayList<>(); + List> relationList = new ArrayList<>(); + int order = 1; + + for (EntityIntent entity : entities) { + String name = entity.getName(); + if (name == null || name.isBlank()) { + LOGGER.warn("Skipping unnamed entity in intent"); + continue; + } + boolean dependent = dependentEntities.contains(name); + Map entityMap = entityDefaults(name, entity.getDescription(), dependent, order++); + + List> properties = new ArrayList<>(); + for (FieldIntent field : entity.getFields()) { + if (field.getName() == null || field.getName() + .isBlank()) { + continue; + } + properties.add(propertyMap(name, field)); + } + for (RelationIntent relation : entity.getRelations()) { + if (!"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { + continue; + } + if (relation.getName() == null || relation.getTo() == null) { + continue; + } + properties.add(relationProperty(name, relation)); + relationList.add(relationLink(name, relation)); + } + entityMap.put("properties", properties); + entityList.add(entityMap); + } + + Map body = new LinkedHashMap<>(); + body.put("entities", entityList); + if (!relationList.isEmpty()) { + body.put("relations", relationList); + } + Map root = new LinkedHashMap<>(); + root.put("model", body); + return root; + } + + /** + * Build the set of entity names that are owned by another entity through an outgoing + * {@code manyToOne}. Used to decide PRIMARY vs DEPENDENT and MASTER vs DETAILS layouts. + */ + private static Set computeDependents(List entities) { + Map result = new HashMap<>(); + for (EntityIntent entity : entities) { + for (RelationIntent relation : entity.getRelations()) { + if ("manyToOne".equals(relation.getKind()) && entity.getName() != null) { + result.put(entity.getName(), true); + } + } + } + return result.keySet(); + } + + private static Map entityDefaults(String name, String description, boolean dependent, int order) { + String dataName = toUpperSnake(name); + Map entity = new LinkedHashMap<>(); + entity.put("name", name); + entity.put("dataName", dataName); + entity.put("dataCount", "SELECT COUNT(*) AS COUNT FROM \"${tablePrefix}" + dataName + "\""); + entity.put("dataQuery", ""); + entity.put("type", dependent ? "DEPENDENT" : "PRIMARY"); + entity.put("title", name); + entity.put("caption", "Manage entity " + name); + entity.put("description", description != null && !description.isBlank() ? description : "Manage entity " + name); + entity.put("tooltip", name); + entity.put("icon", DEFAULT_ICON); + entity.put("menuKey", name.toLowerCase(Locale.ROOT)); + entity.put("menuLabel", name); + entity.put("menuIndex", "100"); + entity.put("layoutType", dependent ? "MANAGE_DETAILS" : "MANAGE_MASTER"); + entity.put("perspectiveName", name); + entity.put("perspectiveLabel", name); + entity.put("perspectiveHeader", ""); + entity.put("perspectiveIcon", DEFAULT_ICON); + entity.put("perspectiveOrder", Integer.toString(order)); + entity.put("perspectiveNavId", ""); + entity.put("perspectiveRole", ""); + entity.put("generateReport", "false"); + entity.put("generateDefaultRoles", "false"); + return entity; + } + + private static Map propertyMap(String entityName, FieldIntent field) { + String column = toUpperSnake(entityName) + "_" + toUpperSnake(field.getName()); + String dataType = mapDataType(field.getType()); + Map p = new LinkedHashMap<>(); + p.put("name", field.getName()); + p.put("description", field.getDescription() == null ? "" : field.getDescription()); + p.put("tooltip", ""); + p.put("dataName", column); + p.put("dataType", dataType); + p.put("dataNullable", field.isRequired() || field.isPrimaryKey() ? "false" : "true"); + if (field.isPrimaryKey()) { + p.put("dataPrimaryKey", "true"); + } + if (field.isPrimaryKey() && field.isGenerated()) { + p.put("dataAutoIncrement", "true"); + } + Integer length = field.getLength() != null ? field.getLength() : defaultLength(dataType); + if (length != null && length > 0) { + p.put("dataLength", length.toString()); + } + if ("DECIMAL".equals(dataType)) { + p.put("dataPrecision", "16"); + p.put("dataScale", "2"); + } + if (field.getDefaultValue() != null && !field.getDefaultValue() + .isBlank()) { + p.put("dataDefaultValue", field.getDefaultValue()); + } + p.put("widgetType", widgetForType(dataType)); + p.put("widgetSize", ""); + p.put("widgetLength", length == null ? "20" : length.toString()); + p.put("widgetIsMajor", "true"); + return p; + } + + /** + * FK property added to the owning entity for a {@code manyToOne}/{@code oneToOne} relation. Renders + * as a DROPDOWN bound to the target entity's Id/Name. + */ + private static Map relationProperty(String ownerEntity, RelationIntent relation) { + String column = toUpperSnake(ownerEntity) + "_" + toUpperSnake(relation.getName()); + Map p = new LinkedHashMap<>(); + p.put("name", relation.getName()); + p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); + p.put("tooltip", ""); + p.put("dataName", column); + p.put("dataType", "INTEGER"); + p.put("dataNullable", relation.isRequired() ? "false" : "true"); + p.put("relationshipType", "COMPOSITION"); + p.put("relationshipCardinality", "1_n"); + p.put("relationshipName", relation.getName()); + p.put("widgetType", "DROPDOWN"); + p.put("widgetSize", ""); + p.put("widgetLength", "20"); + p.put("widgetIsMajor", "true"); + p.put("widgetDropDownKey", "Id"); + p.put("widgetDropDownValue", "Name"); + return p; + } + + /** + * Top-level {@code } link that the EDM editor uses to render arrows on the canvas. + */ + private static Map relationLink(String ownerEntity, RelationIntent relation) { + Map link = new LinkedHashMap<>(); + String linkName = ownerEntity + "_" + relation.getName(); + link.put("name", linkName); + link.put("type", "relation"); + link.put("entity", ownerEntity); + link.put("relationName", linkName); + link.put("relationshipEntityPerspectiveName", relation.getTo()); + link.put("relationshipEntityPerspectiveLabel", "Entities"); + link.put("property", relation.getName()); + link.put("referenced", relation.getTo()); + link.put("referencedProperty", "Id"); + return link; + } + + private static String mapDataType(String type) { + if (type == null) { + return "VARCHAR"; + } + switch (type.toLowerCase(Locale.ROOT)) { + case "integer": + case "int": + return "INTEGER"; + case "long": + return "BIGINT"; + case "decimal": + case "double": + return "DECIMAL"; + case "boolean": + return "BOOLEAN"; + case "date": + return "DATE"; + case "timestamp": + return "TIMESTAMP"; + case "text": + return "CLOB"; + case "uuid": + case "string": + default: + return "VARCHAR"; + } + } + + private static String widgetForType(String dataType) { + switch (dataType) { + case "INTEGER": + case "BIGINT": + case "DECIMAL": + return "NUMBER"; + case "DATE": + return "DATE"; + case "TIMESTAMP": + return "DATETIME-LOCAL"; + case "BOOLEAN": + return "CHECKBOX"; + case "CLOB": + return "TEXTAREA"; + case "VARCHAR": + default: + return "TEXTBOX"; + } + } + + private static Integer defaultLength(String dataType) { + switch (dataType) { + case "VARCHAR": + return 100; + case "DECIMAL": + return 16; + case "INTEGER": + case "BIGINT": + return 20; + default: + return null; + } + } + + /** + * Camel-/Pascal-case to upper snake. Handles {@code IDValue} -> {@code ID_VALUE}. + */ + private static String toUpperSnake(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(name.length() + 8); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + out.append('_'); + } + out.append(Character.toUpperCase(c)); + } + return out.toString(); + } + + /** + * Render the typed model tree as the EDM XML shape. The XML is deliberately minimal - the EDM + * editor accepts files without the {@code } or {@code }-wrapped extension blocks and + * fills its own defaults on first edit. + */ + @SuppressWarnings("unchecked") + private static String renderEdmXml(Map root) { + Map body = (Map) root.get("model"); + List> entities = + body == null ? Collections.emptyList() : (List>) body.getOrDefault("entities", Collections.emptyList()); + List> relations = body == null ? Collections.emptyList() + : (List>) body.getOrDefault("relations", Collections.emptyList()); + + StringBuilder sb = new StringBuilder(4096); + sb.append("\n"); + sb.append(" \n"); + for (Map entity : entities) { + sb.append(" > properties = (List>) entity.getOrDefault("properties", Collections.emptyList()); + for (Map.Entry attr : entity.entrySet()) { + if ("properties".equals(attr.getKey())) { + continue; + } + appendAttribute(sb, attr.getKey(), attr.getValue()); + } + sb.append(">\n"); + for (Map property : properties) { + sb.append(" attr : property.entrySet()) { + appendAttribute(sb, attr.getKey(), attr.getValue()); + } + sb.append(">\n"); + } + sb.append("
\n"); + } + sb.append(" \n"); + for (Map relation : relations) { + sb.append(" attr : relation.entrySet()) { + appendAttribute(sb, attr.getKey(), attr.getValue()); + } + sb.append(">\n"); + } + sb.append("\n"); + return sb.toString(); + } + + private static void appendAttribute(StringBuilder sb, String key, Object value) { + sb.append(' ') + .append(key) + .append("=\"") + .append(escapeXmlAttribute(value == null ? "" : value.toString())) + .append("\""); + } + + private static String escapeXmlAttribute(String raw) { + StringBuilder sb = new StringBuilder(raw.length() + 8); + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append("""); + break; + case '\'': + sb.append("'"); + break; + default: + sb.append(c); + break; + } + } + return sb.toString(); + } + + private static String baseName(IntentGenerationContext context) { + String intentName = context.getIntent() + .getName(); + if (intentName != null && !intentName.isBlank()) { + return intentName; + } + String project = context.getProjectName(); + return project.isEmpty() ? "intent" : project; + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java deleted file mode 100644 index 32405ef65d..0000000000 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/entity/EntityIntentGenerator.java +++ /dev/null @@ -1,291 +0,0 @@ -/* - * 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.entity; - -import java.nio.charset.StandardCharsets; -import java.util.HashSet; -import java.util.Locale; -import java.util.Set; - -import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; -import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; -import org.eclipse.dirigible.components.intent.model.EntityIntent; -import org.eclipse.dirigible.components.intent.model.FieldIntent; -import org.eclipse.dirigible.components.intent.model.IntentModel; -import org.eclipse.dirigible.components.intent.model.RelationIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -/** - * Emits a {@code Entity.ts} decorator-driven TypeScript file under {@code gen/} for - * every entity declared in an intent. The output matches the canonical Dirigible entity shape (see - * {@code tests/tests-integrations/src/main/resources/typescript/CustomerEntity.ts}) so that the - * existing {@code EntitySynchronizer} (file extension {@code Entity.ts}) picks the files up on the - * next reconciliation cycle and projects them into the Hibernate dynamic-map store. - * - *

- * Idempotent: identical input always produces byte-identical output. Generators must not introduce - * timestamps or stable-but-version-sensitive headers. - */ -@Component -@Order(100) -public class EntityIntentGenerator implements IntentTargetGenerator { - - private static final Logger LOGGER = LoggerFactory.getLogger(EntityIntentGenerator.class); - - /** SQL column-name length cap for the auto-derived names. Keeps Oracle / older RDBMS happy. */ - private static final int MAX_COLUMN_NAME_LENGTH = 30; - - @Override - public String name() { - return "entity"; - } - - @Override - public void generate(IntentGenerationContext context) { - IntentModel model = context.getModel(); - if (model.getEntities() - .isEmpty()) { - return; - } - IRepository repository = context.getRepository(); - String genRoot = context.getGenRoot(); - Set seenFqns = new HashSet<>(); - for (EntityIntent entity : model.getEntities()) { - if (entity.getName() == null || entity.getName() - .isBlank()) { - LOGGER.warn("Skipping unnamed entity in intent [{}]", context.getIntent() - .getName()); - continue; - } - String fileName = entity.getName() + "Entity.ts"; - String path = genRoot + "/" + fileName; - if (!seenFqns.add(path)) { - LOGGER.warn("Duplicate entity [{}] in intent [{}] - keeping the first occurrence", entity.getName(), context.getIntent() - .getName()); - continue; - } - String source = render(entity); - writeResource(repository, path, source); - } - } - - private static String render(EntityIntent entity) { - StringBuilder sb = new StringBuilder(); - sb.append("// Generated from .intent - do not edit by hand. Edit the .intent and republish.\n"); - sb.append("@Entity(\"") - .append(entity.getName()) - .append("\")\n"); - sb.append("@Table(\"") - .append(toUpperSnake(entity.getName())) - .append("\")\n"); - sb.append("export class ") - .append(entity.getName()) - .append(" {\n"); - for (int i = 0; i < entity.getFields() - .size(); i++) { - FieldIntent field = entity.getFields() - .get(i); - appendField(sb, entity, field); - if (i < entity.getFields() - .size() - - 1 - || !entity.getRelations() - .isEmpty()) { - sb.append('\n'); - } - } - for (int i = 0; i < entity.getRelations() - .size(); i++) { - RelationIntent relation = entity.getRelations() - .get(i); - appendRelation(sb, relation); - if (i < entity.getRelations() - .size() - - 1) { - sb.append('\n'); - } - } - sb.append("}\n"); - return sb.toString(); - } - - private static void appendField(StringBuilder sb, EntityIntent entity, FieldIntent field) { - if (field.getName() == null || field.getName() - .isBlank()) { - return; - } - if (field.isPrimaryKey()) { - sb.append(" @Id()\n"); - } - if (field.isGenerated()) { - sb.append(" @Generated(\"sequence\")\n"); - } - sb.append(" @Column({ name: \"") - .append(toColumnName(entity.getName(), field.getName())) - .append("\", type: \"") - .append(mapType(field.getType())) - .append("\""); - if (field.getLength() != null && field.getLength() > 0) { - sb.append(", length: ") - .append(field.getLength()); - } - if (field.isRequired() || field.isPrimaryKey()) { - sb.append(", nullable: false"); - } - if (field.getDefaultValue() != null && !field.getDefaultValue() - .isBlank()) { - sb.append(", defaultValue: \"") - .append(field.getDefaultValue()) - .append("\""); - } - sb.append(" })\n"); - sb.append(" public ") - .append(field.getName()) - .append(": ") - .append(mapTsType(field.getType())) - .append(";\n"); - } - - private static void appendRelation(StringBuilder sb, RelationIntent relation) { - if (relation.getName() == null || relation.getTo() == null) { - return; - } - String kind = relation.getKind() == null ? "manyToOne" : relation.getKind(); - switch (kind) { - case "oneToMany": - sb.append(" @OneToMany(() => ") - .append(relation.getTo()) - .append(", { joinColumn: \"") - .append(toColumnName(relation.getTo(), "id")) - .append("\" })\n"); - sb.append(" public ") - .append(relation.getName()) - .append(": ") - .append(relation.getTo()) - .append("[];\n"); - break; - case "manyToOne": - default: - sb.append(" @ManyToOne(() => ") - .append(relation.getTo()) - .append(", { joinColumn: \"") - .append(toColumnName(relation.getTo(), "id")) - .append("\" })\n"); - sb.append(" public ") - .append(relation.getName()) - .append(": ") - .append(relation.getTo()) - .append(";\n"); - break; - } - } - - /** - * Map an intent logical field type to a Dirigible SDK column type string. Unknown types fall back - * to {@code string} - generators must never crash on a typo; the engine logs a warning instead. - */ - private static String mapType(String type) { - if (type == null) { - return "string"; - } - switch (type.toLowerCase(Locale.ROOT)) { - case "integer": - case "int": - return "integer"; - case "long": - return "long"; - case "decimal": - case "double": - return "double"; - case "boolean": - return "boolean"; - case "date": - return "date"; - case "uuid": - return "string"; - case "text": - return "string"; - case "string": - default: - return "string"; - } - } - - /** - * Map the intent logical type to the TypeScript property type. - */ - private static String mapTsType(String type) { - if (type == null) { - return "string"; - } - switch (type.toLowerCase(Locale.ROOT)) { - case "integer": - case "int": - case "long": - case "decimal": - case "double": - return "number"; - case "boolean": - return "boolean"; - case "date": - return "Date"; - case "uuid": - case "text": - case "string": - default: - return "string"; - } - } - - /** - * Convert a camelCase or PascalCase identifier to UPPER_SNAKE_CASE for the column name. Capped at - * {@link #MAX_COLUMN_NAME_LENGTH} characters. - */ - private static String toUpperSnake(String name) { - if (name == null || name.isEmpty()) { - return ""; - } - StringBuilder out = new StringBuilder(name.length() + 8); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { - out.append('_'); - } - out.append(Character.toUpperCase(c)); - } - return out.toString(); - } - - /** - * Produce a stable column name of the form {@code _}, snake-cased and capped. - */ - private static String toColumnName(String entityName, String fieldName) { - String raw = toUpperSnake(entityName) + "_" + toUpperSnake(fieldName); - if (raw.length() <= MAX_COLUMN_NAME_LENGTH) { - return raw; - } - return raw.substring(0, MAX_COLUMN_NAME_LENGTH); - } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } -} 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 4aaf961509..17f710ea83 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 @@ -9,8 +9,21 @@ */ package org.eclipse.dirigible.components.intent.parser; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.FormIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.ProcessIntent; +import org.eclipse.dirigible.components.intent.model.RelationIntent; +import org.eclipse.dirigible.components.intent.model.ReportIntent; +import org.eclipse.dirigible.components.intent.model.StepIntent; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; @@ -23,16 +36,29 @@ *

* SafeConstructor blocks the {@code !!type} / {@code !!new} tags - YAML deserialisation of intents * authored by an LLM or pasted from the web must never become a code-execution surface. + * + *

+ * Structural validation runs after deserialisation: duplicate names, dangling relation targets, + * unknown field / relation / step kinds, and dangling form-entity references are surfaced via + * {@link IntentValidationException}. The set of {@link IntentValidationException#getIssues() + * issues} carries every problem found in one pass rather than failing fast - a usable error message + * lists everything the author needs to fix. */ public final class IntentParser { + private static final Set FIELD_TYPES = + Set.of("string", "text", "integer", "int", "long", "decimal", "double", "boolean", "date", "timestamp", "uuid"); + private static final Set RELATION_KINDS = Set.of("oneToMany", "manyToOne", "oneToOne", "manyToMany"); + private static final Set STEP_KINDS = Set.of("userTask", "serviceTask", "decision", "script", "end"); + private IntentParser() {} /** - * Parse the given YAML source into an {@link IntentModel}. + * Parse and validate the given YAML source. * * @param yaml the raw YAML content of an {@code .intent} file (may be null or blank) * @return the typed model, never null - an empty model is returned for blank input + * @throws IntentValidationException if structural problems are found in the model */ public static IntentModel parse(String yaml) { if (yaml == null || yaml.isBlank()) { @@ -45,6 +71,153 @@ public static IntentModel parse(String yaml) { } String json = JsonHelper.toJson(tree); IntentModel model = JsonHelper.fromJson(json, IntentModel.class); - return model == null ? new IntentModel() : model; + if (model == null) { + return new IntentModel(); + } + validate(model); + return model; + } + + /** + * Run all structural checks. Collects every issue before throwing so authors get one complete error + * message rather than playing whack-a-mole. + */ + private static void validate(IntentModel model) { + List issues = new ArrayList<>(); + Set entityNames = validateEntities(model, issues); + validateProcesses(model, issues); + validateForms(model, entityNames, issues); + validateReports(model, entityNames, issues); + if (!issues.isEmpty()) { + throw new IntentValidationException(issues); + } + } + + private static Set validateEntities(IntentModel model, List issues) { + Set entityNames = new HashSet<>(); + for (EntityIntent entity : model.getEntities()) { + String name = entity.getName(); + if (name == null || name.isBlank()) { + issues.add("entity has no name"); + continue; + } + if (!entityNames.add(name)) { + issues.add("duplicate entity [" + name + "]"); + } + Set fieldNames = new HashSet<>(); + int idCount = 0; + for (FieldIntent field : entity.getFields()) { + if (field.getName() == null || field.getName() + .isBlank()) { + issues.add("entity [" + name + "] has a field with no name"); + continue; + } + if (!fieldNames.add(field.getName())) { + issues.add("entity [" + name + "] declares field [" + field.getName() + "] twice"); + } + if (field.getType() != null && !FIELD_TYPES.contains(field.getType() + .toLowerCase(Locale.ROOT))) { + issues.add("entity [" + name + "] field [" + field.getName() + "] has unknown type [" + field.getType() + "]"); + } + if (field.isPrimaryKey()) { + idCount++; + } + } + if (idCount > 1) { + issues.add("entity [" + name + "] declares " + idCount + " primary-key fields - exactly one is allowed"); + } + } + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() == null) { + continue; + } + for (RelationIntent relation : entity.getRelations()) { + if (relation.getName() == null || relation.getName() + .isBlank()) { + issues.add("entity [" + entity.getName() + "] has a relation with no name"); + continue; + } + if (relation.getKind() != null && !RELATION_KINDS.contains(relation.getKind())) { + issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] has unknown kind [" + + relation.getKind() + "]"); + } + if (relation.getTo() == null || relation.getTo() + .isBlank()) { + issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] has no target"); + } else if (!entityNames.contains(relation.getTo())) { + issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + "] points to unknown entity [" + + relation.getTo() + "]"); + } + } + } + return entityNames; + } + + private static void validateProcesses(IntentModel model, List issues) { + Set processNames = new HashSet<>(); + for (ProcessIntent process : model.getProcesses()) { + if (process.getName() == null || process.getName() + .isBlank()) { + issues.add("process has no name"); + continue; + } + if (!processNames.add(process.getName())) { + issues.add("duplicate process [" + process.getName() + "]"); + } + Set stepNames = new HashSet<>(); + for (StepIntent step : process.getSteps()) { + if (step.getName() == null || step.getName() + .isBlank()) { + issues.add("process [" + process.getName() + "] has a step with no name"); + continue; + } + if (!stepNames.add(step.getName())) { + issues.add("process [" + process.getName() + "] declares step [" + step.getName() + "] twice"); + } + if (step.getKind() != null && !STEP_KINDS.contains(step.getKind())) { + issues.add( + "process [" + process.getName() + "] step [" + step.getName() + "] has unknown kind [" + step.getKind() + "]"); + } + } + } + } + + private static void validateForms(IntentModel model, Set entityNames, List issues) { + Set formNames = new HashSet<>(); + for (FormIntent form : model.getForms()) { + if (form.getName() == null || form.getName() + .isBlank()) { + issues.add("form has no name"); + continue; + } + if (!formNames.add(form.getName())) { + issues.add("duplicate form [" + form.getName() + "]"); + } + if (form.getForEntity() != null && !form.getForEntity() + .isBlank() + && !entityNames.contains(form.getForEntity())) { + issues.add("form [" + form.getName() + "] references unknown entity [" + form.getForEntity() + "]"); + } + } + } + + private static void validateReports(IntentModel model, Set entityNames, List issues) { + Set reportNames = new HashSet<>(); + for (ReportIntent report : model.getReports()) { + if (report.getName() == null || report.getName() + .isBlank()) { + issues.add("report has no name"); + continue; + } + if (!reportNames.add(report.getName())) { + issues.add("duplicate report [" + report.getName() + "]"); + } + if (report.getSource() == null || report.getSource() + .isBlank()) { + issues.add("report [" + report.getName() + "] has no source"); + } else if (!entityNames.contains(report.getSource())) { + issues.add("report [" + report.getName() + "] sources from unknown entity [" + report.getSource() + "]"); + } + } } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentValidationException.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentValidationException.java new file mode 100644 index 0000000000..dc364d7ccd --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentValidationException.java @@ -0,0 +1,46 @@ +/* + * 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 java.util.Collections; +import java.util.List; + +/** + * Thrown by {@link IntentParser} when the parsed model contains structural problems (duplicate + * names, dangling references, unknown enum-like values). Carries the full list of issues so the + * author can fix all of them in one edit. + */ +public class IntentValidationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final List issues; + + public IntentValidationException(List issues) { + super(formatMessage(issues)); + this.issues = Collections.unmodifiableList(issues); + } + + public List getIssues() { + return issues; + } + + private static String formatMessage(List issues) { + StringBuilder sb = new StringBuilder("Intent validation failed (").append(issues.size()) + .append(" issue") + .append(issues.size() == 1 ? "" : "s") + .append("):"); + for (String issue : issues) { + sb.append("\n - ") + .append(issue); + } + return sb.toString(); + } +} From f3b061e56b32a2b50bae6a82dcfc20ea51a4700a Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 10:52:31 +0300 Subject: [PATCH 05/28] Add BpmnIntentGenerator: processes -> gen/.bpmn Second concrete intent generator (after EdmIntentGenerator). For every ProcessIntent in the intent, writes one BPMN 2.0 file under gen/ in the Flowable flavour the existing BpmnSynchronizer consumes. What it emits: - One , one , one . - userTask -> - serviceTask -> - script -> same shape as serviceTask - decision -> with default="flow__default", plus a conditioned outgoing flow to args.then carrying - end -> the canonical ; the explicit step's outgoing flow targets the single shared end event Sequence flows are emitted linearly between consecutive effective step IDs (start -> step1 -> ... -> end), with consecutive end-event entries collapsed so an author-declared `end` step doesn't double-emit. Path-free references per the CLAUDE.md "no template-engine paths" rule: flowable:formKey is the bare form name from args.form (a deployment-time form-key resolver maps it to a generated page), and args.call for service tasks is passed through verbatim - it should reference a hand- authored handler under custom/, never a template output. No BPMN diagram block (bpmndi). Flowable executes without it; the BPMN editor in the IDE auto-lays out a missing diagram on first open. Skipping it keeps the generator deterministic and avoids spurious x/y churn between regenerations. @Order(300), slotted between EdmIntentGenerator (200) and the future form (400) / report (500) / permissions (600) generators. CLAUDE.md updated: the generator-table row for processes is now done, both worked examples are mentioned in the layout block, and the follow- ups list shrinks accordingly. quick-build + formatter:validate + release-profile javadoc gate all clean on the touched modules. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 17 +- .../generator/bpmn/BpmnIntentGenerator.java | 359 ++++++++++++++++++ 2 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 0f5570cdf8..5ee5cd44c8 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -88,7 +88,8 @@ components/engine/engine-intent/ │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + projectName + IRepository │ ├── IntentRegenerationService.java # collects every SPI bean and runs them in @Order - │ └── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) + │ ├── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) + │ └── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -98,14 +99,19 @@ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. - `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. -One concrete generator currently lives in-module: [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. It is the worked example - additional slice generators follow the same pattern. +Two concrete generators currently live in-module: + +- [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. +- [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `gen/.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 fallthrough. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. + +Together they are the worked examples; additional slice generators follow the same pattern. The remaining generators each map one intent block to one model-layer extension: -| Intent block | Output | Spring `@Order` (suggested) | +| Intent block | Output | Spring `@Order` | |---|---|---| | `entities` | `gen/.edm` + `gen/.model` | 200 (done) | -| `processes[]` | `gen/.bpmn` (one per process) | 300 | +| `processes[]` | `gen/.bpmn` (one per process) | 300 (done) | | `forms[]` | `gen/.form` | 400 | | `reports[]` | `gen/.report` | 500 | | `permissions` | `gen/.roles` + `gen/.access` | 600 | @@ -213,7 +219,7 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci ## Follow-ups -- Remaining model-layer generators: BPMN (process), `.form`, `.report`, `.roles` + `.access` (permissions). DSM / schema / table / view / csvim / csv are lower-priority because the EDM the first generator writes already covers the same surface implicitly. +- Remaining model-layer generators: `.form`, `.report`, `.roles` + `.access` (permissions). DSM / schema / table / view / csvim / csv are lower-priority because the EDM the first generator writes already covers the same surface implicitly. - Trigger the "Generate from EDM" template programmatically on intent change so the developer sees the full app, not just the model files. Today the user has to open the EDM editor and click Generate manually. - Claude chat + patch-preview in the perspective. Needs a separate LLM bridge module (Anthropic API key via `DirigibleConfig`, request shaping, structured-patch responses, accept / reject flow). Out of scope for this PR. - Read-only Monaco model for paths under `**/gen/**` so the IDE marks them not-for-editing. @@ -225,3 +231,4 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci **Done:** - Structural validation on parse: duplicate names, dangling relation / form / report targets, unknown field / relation / step kinds. Surfaced via `IntentValidationException` with the complete list of issues in one error message. +- `EdmIntentGenerator` (entities -> .edm + .model) and `BpmnIntentGenerator` (processes -> .bpmn). 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 new file mode 100644 index 0000000000..0fcfe97a63 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java @@ -0,0 +1,359 @@ +/* + * 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.bpmn; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.ProcessIntent; +import org.eclipse.dirigible.components.intent.model.StepIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits one {@code gen/.bpmn} per {@link ProcessIntent} declared in the intent. The output + * is a minimal Flowable-flavoured BPMN 2.0 document: + *

    + *
  • one {@code } and one {@code } per process
  • + *
  • {@code userTask} -> + * {@code }
  • + *
  • {@code serviceTask} / {@code script} -> + * {@code } with the {@code handler} extension + * element pointing at {@code args.call}
  • + *
  • {@code decision} -> {@code } plus a conditioned outgoing flow to + * {@code args.then} and a default flow to the next step
  • + *
  • {@code end} -> the canonical end event (no separate element; the outgoing flow targets the + * single {@code })
  • + *
+ * + *

+ * No BPMN diagram block ({@code bpmndi}) is emitted. Flowable executes without it; the BPMN + * editor in the IDE auto-lays out a missing diagram on first open. Skipping it keeps the generator + * deterministic and trivially stable across re-runs (no spurious x/y churn). + * + *

+ * Path-free references. Per the CLAUDE.md "no template-engine paths" rule, + * {@code flowable:formKey} carries the bare form name from {@code args.form}, not a rendered HTML + * URL; the deployment-time form-key resolver maps it to a generated page. {@code args.call} is + * passed through verbatim - it should reference a hand-authored handler under {@code custom/}, not + * a template output. + * + *

+ * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(300) +public class BpmnIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(BpmnIntentGenerator.class); + + private static final String START_ID = "start"; + private static final String END_ID = "end"; + + @Override + public String name() { + return "bpmn"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getProcesses() + .isEmpty()) { + return; + } + IRepository repository = context.getRepository(); + String genRoot = context.getGenRoot(); + Set seenFiles = new HashSet<>(); + for (ProcessIntent process : model.getProcesses()) { + if (process.getName() == null || process.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed process in intent [{}]", context.getIntent() + .getName()); + continue; + } + String fileName = process.getName() + ".bpmn"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate process [{}] in intent [{}] - keeping the first occurrence", process.getName(), context.getIntent() + .getName()); + continue; + } + String content = render(process); + writeResource(repository, genRoot + "/" + fileName, content); + } + } + + private static String render(ProcessIntent process) { + List steps = process.getSteps(); + List effectiveSteps = buildEffectiveStepIds(steps); + StringBuilder sb = new StringBuilder(2048); + sb.append("\n"); + sb.append("\n"); + sb.append(" \n"); + sb.append(" \n"); + for (StepIntent step : steps) { + if (step.getName() == null || step.getName() + .isBlank()) { + continue; + } + appendStepElement(sb, step); + } + sb.append(" \n"); + appendSequenceFlows(sb, steps, effectiveSteps); + sb.append(" \n"); + sb.append("\n"); + return sb.toString(); + } + + /** + * Build the list of step IDs that participate in the default linear flow. {@code decision} steps + * participate (they are an exclusiveGateway with a default outgoing edge), but {@code end} steps + * are translated to the canonical end event - their IDs are replaced by {@link #END_ID} in the + * sequence. + */ + private static List buildEffectiveStepIds(List steps) { + List ids = new ArrayList<>(); + ids.add(START_ID); + for (StepIntent step : steps) { + if (step.getName() == null || step.getName() + .isBlank()) { + continue; + } + if ("end".equalsIgnoreCase(step.getKind())) { + ids.add(END_ID); + } else { + ids.add(step.getName()); + } + } + ids.add(END_ID); + return dedupeConsecutive(ids); + } + + /** + * Collapse repeated IDs (an author-declared {@code end} step followed by the implicit end leaves a + * duplicate {@link #END_ID} entry). One end target is enough. + */ + private static List dedupeConsecutive(List ids) { + List out = new ArrayList<>(ids.size()); + String previous = null; + for (String id : ids) { + if (!Objects.equals(id, previous)) { + out.add(id); + } + previous = id; + } + return out; + } + + private static void appendStepElement(StringBuilder sb, StepIntent step) { + String kind = step.getKind() == null ? "userTask" : step.getKind(); + switch (kind) { + case "userTask": + appendUserTask(sb, step); + break; + case "serviceTask": + case "script": + appendServiceTask(sb, step); + break; + case "decision": + appendExclusiveGateway(sb, step); + break; + case "end": + break; + default: + LOGGER.warn("Unknown step kind [{}] for step [{}] - rendering as userTask", kind, step.getName()); + appendUserTask(sb, step); + break; + } + } + + private static void appendUserTask(StringBuilder sb, StepIntent step) { + String assignee = stringArg(step, "assignee"); + String form = stringArg(step, "form"); + sb.append(" \n"); + } + + private static void appendServiceTask(StringBuilder sb, StepIntent step) { + String call = stringArg(step, "call"); + sb.append(" \n"); + if (call != null && !call.isBlank()) { + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + } + sb.append(" \n"); + } + + private static void appendExclusiveGateway(StringBuilder sb, StepIntent step) { + sb.append(" \n"); + } + + /** + * Emit the sequence flows. The default is a linear chain through {@link #buildEffectiveStepIds}. + * Decision steps emit an extra conditioned flow to {@code args.then}; the default chained flow to + * the next step in order remains and is marked as the gateway default by ID convention. + */ + private static void appendSequenceFlows(StringBuilder sb, List steps, List effectiveIds) { + for (int i = 0; i < effectiveIds.size() - 1; i++) { + String source = effectiveIds.get(i); + String target = effectiveIds.get(i + 1); + String flowId; + if (isDecisionId(source, steps)) { + flowId = "flow_" + source + "_default"; + } else { + flowId = "flow_" + source + "_" + target; + } + sb.append(" \n"); + } + for (StepIntent step : steps) { + if (!"decision".equalsIgnoreCase(step.getKind())) { + continue; + } + String condition = stringArg(step, "if"); + String thenTarget = stringArg(step, "then"); + if (condition == null || condition.isBlank() || thenTarget == null || thenTarget.isBlank()) { + LOGGER.warn("Decision [{}] is missing `if` or `then` - skipping conditioned outgoing flow", step.getName()); + continue; + } + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + } + } + + private static boolean isDecisionId(String stepId, List steps) { + for (StepIntent step : steps) { + if (stepId.equals(step.getName()) && "decision".equalsIgnoreCase(step.getKind())) { + return true; + } + } + return false; + } + + private static String stringArg(StepIntent step, String key) { + Map args = step.getArgs(); + if (args == null) { + return null; + } + Object value = args.get(key); + return value == null ? null : value.toString(); + } + + private static String escapeXmlAttribute(String raw) { + if (raw == null) { + return ""; + } + StringBuilder sb = new StringBuilder(raw.length() + 8); + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append("""); + break; + case '\'': + sb.append("'"); + break; + default: + sb.append(c); + break; + } + } + return sb.toString(); + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} From 05ffdcf4e8b9ff28e3624272c92bbcc81a5e71c0 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 11:08:59 +0300 Subject: [PATCH 06/28] Add FormIntentGenerator + comprehensive Orders IT FormIntentGenerator (@Order 400) writes one gen/.form per FormIntent. Output is the JSON shape consumed by the form-builder editor in the IDE - metadata, feeds, scripts, code, plus a form array containing: - a header control with the form name (or description) - one input control per declared field, typed by looking up the field on the bound forEntity: string / uuid -> input-textfield text -> input-textarea integer / long / decimal / double -> input-number boolean -> input-checkbox date -> input-date timestamp -> input-datetime-local - a trailing container-hbox with one button per declared action; button colour is inferred from the action name (approve -> positive, reject/decline/delete/cancel -> negative, save/submit -> emphasized) - a code block with onClicked stubs (TODOs); wiring to a backend is the downstream template engine's or a /custom/ override's job Path-free per the CLAUDE.md rule: no template-output URLs in the generated form. Field labels are humanized (orderDate -> "Order Date", from_date -> "From Date"). IntentEngineIT (new) is the worked end-to-end test: - HTTP-only (extends IntegrationTest, no Selenide) - One comprehensive Orders app.intent declares four entities (Customer/Product/Order/OrderItem) with relations in both directions, an OrderApproval process with every step kind (userTask + decision + serviceTask + end), two forms bound to entities (different action sets), one report and three permission roles - Writes the intent through IRepository, forces sync, then asserts: * the Intent JPA artefact is persisted (IntentService) * gen/orders.edm + gen/orders.model exist with every entity name, the right widget types per field type (NUMBER for decimal, DATE for date, CHECKBOX for boolean, TEXTAREA for text), the PRIMARY/DEPENDENT split (Order and OrderItem become DEPENDENT via incoming manyToOne edges), and all three referenced relation targets (Customer / Order / Product) * gen/OrderApproval.bpmn carries startEvent/endEvent, the userTask with candidateGroups + bare-name formKey, the exclusiveGateway, the serviceTask with delegateExpression=${JSTask}, the handler reference and the ${amount > 10000} condition expression * gen/ApproveOrder.form / gen/NewCustomer.form have the right typed controls per field, humanized labels, action buttons in the right colours, and the onClicked stubs in the code * GET/POST /services/ide/intent/* endpoints list the project, return the parsed model with the correct sizes (4 entities, 5 process steps, 2 forms, 1 report, 3 permissions), echo the raw YAML source and trigger an explicit regenerate - A second test verifies that removing the .intent file cleans the persisted artefact CLAUDE.md updated: generator table marks .form done, layout block shows the three concrete generators, follow-ups shrinks to .report and .roles/.access. quick-build install, formatter:validate, release-profile javadoc gate, and a clean compile of tests-integrations all pass. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 11 +- .../generator/form/FormIntentGenerator.java | 368 ++++++++++++++++++ .../integration/tests/api/IntentEngineIT.java | 314 +++++++++++++++ 3 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java create mode 100644 tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 5ee5cd44c8..bf53b2cdba 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -89,7 +89,8 @@ components/engine/engine-intent/ │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + projectName + IRepository │ ├── IntentRegenerationService.java # collects every SPI bean and runs them in @Order │ ├── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) - │ └── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process + │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process + │ └── form/FormIntentGenerator.java # @Order(400); writes gen/.form per form (typed controls + action buttons + stub code) ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -99,10 +100,11 @@ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. - `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. -Two concrete generators currently live in-module: +Three concrete generators currently live in-module: - [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. - [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `gen/.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 fallthrough. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. +- [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `gen/.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). 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/`. Together they are the worked examples; additional slice generators follow the same pattern. @@ -112,7 +114,7 @@ The remaining generators each map one intent block to one model-layer extension: |---|---|---| | `entities` | `gen/.edm` + `gen/.model` | 200 (done) | | `processes[]` | `gen/.bpmn` (one per process) | 300 (done) | -| `forms[]` | `gen/.form` | 400 | +| `forms[]` | `gen/.form` | 400 (done) | | `reports[]` | `gen/.report` | 500 | | `permissions` | `gen/.roles` + `gen/.access` | 600 | | (future) `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 | @@ -231,4 +233,5 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci **Done:** - Structural validation on parse: duplicate names, dangling relation / form / report targets, unknown field / relation / step kinds. Surfaced via `IntentValidationException` with the complete list of issues in one error message. -- `EdmIntentGenerator` (entities -> .edm + .model) and `BpmnIntentGenerator` (processes -> .bpmn). +- `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form). +- End-to-end integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering the full Orders pipeline (4 entities + relations + process with every step kind + 2 forms + report + 3 permission roles) and exercising the REST endpoints. HTTP-only, no Selenide. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java new file mode 100644 index 0000000000..95daa9f8d2 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java @@ -0,0 +1,368 @@ +/* + * 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.form; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.FormIntent; +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; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits one {@code gen/.form} per {@link FormIntent} declared in the intent. The output is + * the JSON shape consumed by the form-builder editor in the IDE - a {@code form} array of typed + * controls (header, input-textfield / input-number / input-date / input-checkbox, container-hbox + * with buttons) plus the customary {@code metadata} / {@code feeds} / {@code scripts} / + * {@code code} companions. + * + *

+ * When the form has a {@code forEntity}, every entry in the intent's {@code fields} list is looked + * up against the bound entity to pick a typed control: + *

    + *
  • {@code string} / {@code uuid} -> {@code input-textfield}
  • + *
  • {@code text} -> {@code input-textarea}
  • + *
  • {@code integer} / {@code long} / {@code decimal} / {@code double} -> + * {@code input-number}
  • + *
  • {@code boolean} -> {@code input-checkbox}
  • + *
  • {@code date} -> {@code input-date}
  • + *
  • {@code timestamp} -> {@code input-datetime-local}
  • + *
+ * Fields that are not declared on the bound entity (or forms with no {@code forEntity}) fall back + * to {@code input-textfield}. + * + *

+ * Actions become buttons in a trailing {@code container-hbox}. The button {@code type} is inferred + * from the action name ({@code approve} -> positive, {@code reject}/{@code decline}/{@code delete} + * -> negative, {@code save}/{@code submit} -> emphasized, anything else -> standard). Each button + * carries a {@code callback} like {@code onApproveClicked()} pointing at a stub handler in the + * {@code code} block. The stub does nothing; wiring it to an actual backend is left to the + * downstream template engine or a hand-written form override under {@code custom/}. + * + *

+ * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(400) +public class FormIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(FormIntentGenerator.class); + + @Override + public String name() { + return "form"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getForms() + .isEmpty()) { + return; + } + Map entitiesByName = indexEntities(model); + IRepository repository = context.getRepository(); + String genRoot = context.getGenRoot(); + Set seenFiles = new HashSet<>(); + for (FormIntent form : model.getForms()) { + if (form.getName() == null || form.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed form in intent [{}]", context.getIntent() + .getName()); + continue; + } + String fileName = form.getName() + ".form"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate form [{}] in intent [{}] - keeping the first occurrence", form.getName(), context.getIntent() + .getName()); + continue; + } + EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity()); + Map document = buildForm(form, boundEntity); + writeResource(repository, genRoot + "/" + fileName, JsonHelper.toJson(document)); + } + } + + private static Map indexEntities(IntentModel model) { + Map index = new HashMap<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + index.put(entity.getName(), entity); + } + } + return index; + } + + private static Map buildForm(FormIntent form, EntityIntent entity) { + Map document = new LinkedHashMap<>(); + document.put("metadata", new LinkedHashMap<>()); + document.put("feeds", new ArrayList<>()); + document.put("scripts", new ArrayList<>()); + document.put("code", buildCode(form)); + document.put("form", buildControls(form, entity)); + return document; + } + + private static String buildCode(FormIntent form) { + if (form.getActions() + .isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String action : form.getActions()) { + if (action == null || action.isBlank()) { + continue; + } + sb.append("$scope.on") + .append(pascalCase(action)) + .append("Clicked = function () {\n"); + sb.append(" // TODO: wire ") + .append(action) + .append(" action\n"); + sb.append("};\n\n"); + } + return sb.toString(); + } + + private static List> buildControls(FormIntent form, EntityIntent entity) { + List> controls = new ArrayList<>(); + controls.add(headerControl(form)); + Map fieldsByName = new HashMap<>(); + if (entity != null) { + for (FieldIntent field : entity.getFields()) { + if (field.getName() != null) { + fieldsByName.put(field.getName(), field); + } + } + } + for (String fieldName : form.getFields()) { + if (fieldName == null || fieldName.isBlank()) { + continue; + } + controls.add(fieldControl(fieldName, fieldsByName.get(fieldName))); + } + if (!form.getActions() + .isEmpty()) { + controls.add(actionRow(form)); + } + return controls; + } + + private static Map headerControl(FormIntent form) { + Map header = new LinkedHashMap<>(); + header.put("controlId", "header"); + header.put("groupId", "fb-display"); + String label = form.getDescription() != null && !form.getDescription() + .isBlank() ? form.getDescription() : form.getName(); + header.put("label", label); + header.put("headerSize", 2); + header.put("level", 1); + header.put("padding", "tiny"); + header.put("side", "bottom"); + return header; + } + + private static Map fieldControl(String fieldName, FieldIntent field) { + Control control = pickControl(field); + Map map = new LinkedHashMap<>(); + map.put("controlId", control.controlId); + map.put("groupId", "fb-controls"); + map.put("id", fieldName + "Id"); + map.put("label", humanize(fieldName)); + map.put("horizontal", false); + map.put("isCompact", false); + map.put("readonly", field != null && field.isPrimaryKey() && field.isGenerated()); + if (control.htmlType != null) { + map.put("type", control.htmlType); + } + map.put("model", fieldName); + map.put("required", field != null && field.isRequired()); + if ("input-textfield".equals(control.controlId) || "input-textarea".equals(control.controlId)) { + map.put("minLength", 0); + map.put("maxLength", field != null && field.getLength() != null ? field.getLength() : -1); + map.put("errorMessage", "Incorrect input"); + } + return map; + } + + private static Map actionRow(FormIntent form) { + List> buttons = new ArrayList<>(); + for (String action : form.getActions()) { + if (action == null || action.isBlank()) { + continue; + } + buttons.add(actionButton(action)); + } + Map row = new LinkedHashMap<>(); + row.put("controlId", "container-hbox"); + row.put("groupId", "fb-containers"); + row.put("children", buttons); + row.put("justify", "end"); + return row; + } + + private static Map actionButton(String action) { + Map button = new LinkedHashMap<>(); + button.put("controlId", "button"); + button.put("groupId", "fb-controls"); + button.put("label", humanize(action)); + button.put("type", buttonType(action)); + button.put("isSubmit", true); + button.put("isCompact", false); + button.put("callback", "on" + pascalCase(action) + "Clicked()"); + return button; + } + + private static String buttonType(String action) { + switch (action.toLowerCase(Locale.ROOT)) { + case "approve": + case "accept": + case "confirm": + return "positive"; + case "reject": + case "decline": + case "delete": + case "remove": + case "cancel": + return "negative"; + case "save": + case "submit": + case "create": + case "update": + return "emphasized"; + default: + return "standard"; + } + } + + private static Control pickControl(FieldIntent field) { + if (field == null || field.getType() == null) { + return new Control("input-textfield", "text"); + } + switch (field.getType() + .toLowerCase(Locale.ROOT)) { + case "text": + return new Control("input-textarea", "text"); + case "integer": + case "int": + case "long": + case "decimal": + case "double": + return new Control("input-number", "number"); + case "boolean": + return new Control("input-checkbox", null); + case "date": + return new Control("input-date", "date"); + case "timestamp": + return new Control("input-datetime-local", "datetime-local"); + case "uuid": + case "string": + default: + return new Control("input-textfield", "text"); + } + } + + /** + * Convert a camelCase / snake_case / kebab-case identifier to a human label. {@code firstName} + * becomes {@code First Name}; {@code from_date} becomes {@code From Date}. + */ + private static String humanize(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(raw.length() + 4); + boolean capitalizeNext = true; + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '_' || c == '-') { + out.append(' '); + capitalizeNext = true; + continue; + } + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(raw.charAt(i - 1))) { + out.append(' '); + } + if (capitalizeNext) { + out.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + out.append(c); + } + } + return out.toString(); + } + + /** + * Convert an action name to PascalCase suitable for an Angular callback identifier. + * {@code submit-request} becomes {@code SubmitRequest}. + */ + private static String pascalCase(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(raw.length()); + boolean capitalizeNext = true; + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '_' || c == '-' || c == ' ') { + capitalizeNext = true; + continue; + } + if (capitalizeNext) { + out.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + out.append(c); + } + } + return out.toString(); + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } + + /** + * Internal pairing of form-builder control id with HTML input type. The HTML {@code type} attribute + * is omitted for control IDs that don't take one (e.g. checkbox). + */ + private static final class Control { + final String controlId; + final String htmlType; + + Control(String controlId, String htmlType) { + this.controlId = controlId; + this.htmlType = htmlType; + } + } +} 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 new file mode 100644 index 0000000000..4bf4fe838b --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -0,0 +1,314 @@ +/* + * 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.integration.tests.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; +import org.eclipse.dirigible.components.intent.domain.Intent; +import org.eclipse.dirigible.components.intent.service.IntentService; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.repository.api.IResource; +import org.eclipse.dirigible.tests.base.IntegrationTest; +import org.eclipse.dirigible.tests.framework.restassured.RestAssuredExecutor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * End-to-end test for the engine-intent module: drops a comprehensive Orders {@code app.intent} + * into the registry, triggers reconciliation, and asserts the full intent -> model-file pipeline. + * + *

+ * The intent declares four entities (Customer / Product / Order / OrderItem) with relations in both + * directions, an OrderApproval process with all step kinds (userTask / decision / serviceTask / + * end), two forms bound to entities, a report, and three permission roles. Every + * {@code IntentModel} field defined today is exercised. The test asserts: + *

    + *
  • the {@link Intent} JPA artefact is persisted via {@link IntentService}
  • + *
  • the {@code /services/ide/intent/*} REST endpoints list / fetch / source / regenerate the project
  • + *
  • {@code EdmIntentGenerator} produces a {@code gen/orders.edm} + {@code gen/orders.model} pair + * containing every entity, every property, and every relation
  • + *
  • {@code BpmnIntentGenerator} produces a {@code gen/OrderApproval.bpmn} with the right BPMN + * elements for each step kind and the conditioned outgoing flow on the decision
  • + *
  • {@code FormIntentGenerator} produces a {@code gen/.form} per form with controls typed + * from the bound entity's fields and action buttons
  • + *
+ * + *

+ * HTTP-only - no Selenide, no Chrome. Runs fast enough to be part of the default IT suite. + */ +class IntentEngineIT extends IntegrationTest { + + private static final String PROJECT = "orders"; + private static final String INTENT_LOCATION = "/" + PROJECT + "/app.intent"; + private static final String REGISTRY_INTENT = IRepositoryStructure.PATH_REGISTRY_PUBLIC + INTENT_LOCATION; + private static final String REGISTRY_GEN = IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT + "/gen"; + + private static final String INTENT_YAML = """ + name: orders + description: Order management with approval workflow + version: 1 + + entities: + - name: Customer + description: Buyer account + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: email, type: string, length: 200 } + - { name: country, type: string, length: 2 } + - { name: active, type: boolean, defaultValue: "true" } + relations: + - { name: orders, kind: oneToMany, to: Order } + + - name: Product + description: Product catalog entry + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: description, type: text } + - { name: price, type: decimal, required: true } + - { name: inStock, type: boolean } + + - name: Order + description: Customer order + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: orderDate, type: date, required: true } + - { name: status, type: string, length: 32 } + - { name: total, type: decimal } + relations: + - { name: customer, kind: manyToOne, to: Customer, required: true } + - { name: items, kind: oneToMany, to: OrderItem } + + - name: OrderItem + description: Line item in an order + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: quantity, type: integer, required: true } + - { name: lineTotal, type: decimal } + relations: + - { name: order, kind: manyToOne, to: Order, required: true } + - { name: product, kind: manyToOne, to: Product, required: true } + + processes: + - name: OrderApproval + description: Manager approval for orders above 10000 + trigger: { onCreate: Order } + steps: + - name: managerReview + kind: userTask + args: { assignee: order-manager, form: ApproveOrder } + - name: bigOrder + kind: decision + args: { if: "amount > 10000", then: cfoReview } + - name: cfoReview + kind: userTask + args: { assignee: cfo, form: ApproveOrder } + - name: notifyCustomer + kind: serviceTask + args: { call: "custom/notify-customer.ts" } + - name: done + kind: end + + forms: + - name: ApproveOrder + forEntity: Order + description: Approve or reject an order + fields: [orderDate, status, total] + actions: [approve, reject] + + - name: NewCustomer + forEntity: Customer + description: Create a new customer + fields: [name, email, country, active] + actions: [save, cancel] + + reports: + - name: OrdersByCustomer + source: Order + dimensions: [customer.country] + measures: ["count(*)", "sum(total)"] + + permissions: + - { role: Sales, description: Sales staff, can: [Customer:read, Customer:write, Order:read, Order:create] } + - { role: Manager, description: Sales manager, can: [Order:approve, Order:read] } + - { role: Administrator, description: System admin, can: [Customer:write, Product:write, Order:write] } + """; + + @Autowired + private IRepository repository; + + @Autowired + private SynchronizationProcessor synchronizationProcessor; + + @Autowired + private IntentService intentService; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @Test + void full_intent_pipeline_generates_all_model_files() { + repository.createResource(REGISTRY_INTENT, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + synchronizationProcessor.forceProcessSynchronizers(); + + assertIntentIsPersisted(); + assertEdmAndModelGenerated(); + assertBpmnGenerated(); + assertFormGenerated(); + assertRestEndpoints(); + } + + @Test + void intent_removal_cleans_artefact() { + repository.createResource(REGISTRY_INTENT, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); + synchronizationProcessor.forceProcessSynchronizers(); + assertTrue(intentService.findByLocation(INTENT_LOCATION) + .stream() + .findFirst() + .isPresent(), + "intent should be persisted after publish"); + + repository.removeResource(REGISTRY_INTENT); + synchronizationProcessor.forceProcessSynchronizers(); + + assertTrue(intentService.findByLocation(INTENT_LOCATION) + .isEmpty(), + "intent should be cleaned up after the .intent file is removed and the synchronizer runs"); + } + + private void assertIntentIsPersisted() { + List matches = intentService.findByLocation(INTENT_LOCATION); + assertTrue(!matches.isEmpty(), "intent should be persisted at location " + INTENT_LOCATION); + Intent intent = matches.get(0); + assertNotNull(intent.getContent(), "intent payload should be persisted"); + assertTrue(intent.getContent() + .contains("entities:"), + "intent content should contain the YAML body, not just an empty record"); + } + + private void assertEdmAndModelGenerated() { + IResource edm = repository.getResource(REGISTRY_GEN + "/orders.edm"); + assertTrue(edm.exists(), "gen/orders.edm should be generated"); + String edmXml = new String(edm.getContent(), StandardCharsets.UTF_8); + for (String entityName : List.of("Customer", "Product", "Order", "OrderItem")) { + assertTrue(edmXml.contains("name=\"" + entityName + "\""), "EDM should declare entity [" + entityName + "]"); + } + assertTrue(edmXml.contains("dataPrimaryKey=\"true\""), "EDM should mark at least one property as primary key"); + assertTrue(edmXml.contains("widgetType=\"NUMBER\""), "EDM should map decimal/integer fields to a NUMBER widget"); + assertTrue(edmXml.contains("widgetType=\"DATE\""), "EDM should map the date field to a DATE widget"); + assertTrue(edmXml.contains("widgetType=\"CHECKBOX\""), "EDM should map the boolean field to a CHECKBOX widget"); + assertTrue(edmXml.contains("widgetType=\"TEXTAREA\""), "EDM should map the text field to a TEXTAREA widget"); + assertTrue(edmXml.contains("type=\"PRIMARY\""), "EDM should declare at least one PRIMARY entity"); + assertTrue(edmXml.contains("type=\"DEPENDENT\""), "EDM should mark Order/OrderItem as DEPENDENT through the manyToOne edges"); + assertTrue(edmXml.contains("referenced=\"Customer\""), "EDM should carry the Order->Customer relation"); + assertTrue(edmXml.contains("referenced=\"Order\""), "EDM should carry the OrderItem->Order relation"); + assertTrue(edmXml.contains("referenced=\"Product\""), "EDM should carry the OrderItem->Product relation"); + + IResource modelJson = repository.getResource(REGISTRY_GEN + "/orders.model"); + assertTrue(modelJson.exists(), "gen/orders.model should be generated"); + String modelBody = new String(modelJson.getContent(), StandardCharsets.UTF_8); + assertTrue(modelBody.contains("\"entities\""), "model JSON should have an entities array"); + assertTrue(modelBody.contains("\"OrderItem\""), "model JSON should mention OrderItem"); + } + + private void assertBpmnGenerated() { + IResource bpmn = repository.getResource(REGISTRY_GEN + "/OrderApproval.bpmn"); + assertTrue(bpmn.exists(), "gen/OrderApproval.bpmn should be generated"); + String body = new String(bpmn.getContent(), StandardCharsets.UTF_8); + assertTrue(body.contains(" 10000}"), "BPMN should embed the decision's if expression"); + } + + private void assertFormGenerated() { + IResource form = repository.getResource(REGISTRY_GEN + "/ApproveOrder.form"); + assertTrue(form.exists(), "gen/ApproveOrder.form should be generated"); + String body = new String(form.getContent(), StandardCharsets.UTF_8); + assertTrue(body.contains("\"controlId\":\"header\""), "form should start with a header control"); + assertTrue(body.contains("\"label\":\"Order Date\""), "form should humanize the orderDate field name to 'Order Date'"); + assertTrue(body.contains("\"controlId\":\"input-date\""), "form should pick input-date for the orderDate field"); + assertTrue(body.contains("\"controlId\":\"input-number\""), "form should pick input-number for the total decimal field"); + assertTrue(body.contains("\"type\":\"positive\""), "form should mark the approve button as positive"); + assertTrue(body.contains("\"type\":\"negative\""), "form should mark the reject button as negative"); + assertTrue(body.contains("onApproveClicked"), "form code should declare the approve handler stub"); + + IResource customerForm = repository.getResource(REGISTRY_GEN + "/NewCustomer.form"); + assertTrue(customerForm.exists(), "gen/NewCustomer.form should be generated"); + String customerBody = new String(customerForm.getContent(), StandardCharsets.UTF_8); + assertTrue(customerBody.contains("\"controlId\":\"input-checkbox\""), + "customer form should map active:boolean to an input-checkbox"); + } + + private void assertRestEndpoints() { + restAssuredExecutor.execute(() -> given().when() + .get("/services/ide/intent/projects") + .then() + .statusCode(200) + .body("$", hasItem(PROJECT))); + + restAssuredExecutor.execute(() -> given().when() + .get("/services/ide/intent/projects/" + PROJECT) + .then() + .statusCode(200) + .body("entities", hasSize(greaterThanOrEqualTo(4))) + .body("entities.name", hasItem("Customer")) + .body("entities.name", hasItem("OrderItem")) + .body("processes", hasSize(1)) + .body("processes[0].name", equalTo("OrderApproval")) + .body("processes[0].steps", hasSize(5)) + .body("forms", hasSize(2)) + .body("reports", hasSize(1)) + .body("permissions", hasSize(3))); + + restAssuredExecutor.execute(() -> given().when() + .get("/services/ide/intent/projects/" + PROJECT + "/source") + .then() + .statusCode(200) + .body(containsString("name: orders")) + .body(containsString("OrderApproval"))); + + restAssuredExecutor.execute(() -> given().when() + .post("/services/ide/intent/projects/" + PROJECT + "/regenerate") + .then() + .statusCode(200) + .body("project", equalTo(PROJECT)) + .body("status", equalTo("regenerated"))); + } + + @AfterEach + void removeIntentFromRegistry() { + if (repository.hasResource(REGISTRY_INTENT)) { + repository.removeResource(REGISTRY_INTENT); + synchronizationProcessor.forceProcessSynchronizers(); + } + } +} From 1a2064849e53bf244a6a82920774edc5812e65d3 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 11:22:12 +0300 Subject: [PATCH 07/28] Add ReportIntentGenerator + PermissionIntentGenerator ReportIntentGenerator (@Order 500) writes one gen/.report per ReportIntent. The output is the JSON shape the report editor consumes: outer record with alias / tId / label / baseTable / query plus a columns array. Dimensions become columns with aggregate NONE; measures are parsed by the aggregate(field) convention (count(*) / sum(field) / avg(field) / min(field) / max(field)) into columns with the matching aggregate. Unknown shapes fall back to NONE-aggregate columns carrying the raw text as their name so the editor still loads the file. baseTable is the upper-snake of the source entity name so the .report lines up with the .edm's dataName. query / joins / filters / orders are left empty; the report editor builds the SQL on open from baseTable + columns. PermissionIntentGenerator (@Order 600) writes gen/.roles from the intent's permissions block, deduped by role name with descriptions carried through. It deliberately does NOT emit .access constraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. The can: [Resource:action] tokens on each permission stay as an authoring hint to those downstream generators; the actual mapping is the downstream template's contract, not intent's. Follow-ups list this trade-off. IntentEngineIT extended: asserts gen/OrdersByCustomer.report carries the intent's alias, the upper-snake baseTable, NONE-aggregate dimensions (customer.country preserved verbatim as a dotted path), and parsed COUNT/SUM aggregates from the measure expressions. Asserts gen/orders.roles contains the Sales/Manager/Administrator role entries with descriptions. CLAUDE.md updated: now lists five concrete generators; the generator table marks every defined intent block as done; layout block shows the new packages; follow-ups shrinks to the .access-from-intent question (documented as deferred) and the lower-priority extensions (DSM/CSVIM/ schema-level entries). quick-build install, formatter:validate, release-profile javadoc gate and a clean compile of tests-integrations all pass. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 26 +- .../permission/PermissionIntentGenerator.java | 117 ++++++++ .../report/ReportIntentGenerator.java | 278 ++++++++++++++++++ .../integration/tests/api/IntentEngineIT.java | 29 +- 4 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index bf53b2cdba..ad260129f5 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -88,9 +88,11 @@ components/engine/engine-intent/ │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + projectName + IRepository │ ├── IntentRegenerationService.java # collects every SPI bean and runs them in @Order - │ ├── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) - │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process - │ └── form/FormIntentGenerator.java # @Order(400); writes gen/.form per form (typed controls + action buttons + stub code) + │ ├── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) + │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process + │ ├── form/FormIntentGenerator.java # @Order(400); writes gen/.form per form (typed controls + action buttons + stub code) + │ ├── report/ReportIntentGenerator.java # @Order(500); writes gen/.report per report (dimensions + parsed measures) + │ └── permission/PermissionIntentGenerator.java # @Order(600); writes gen/.roles per intent (deduped role names + descriptions) ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -100,25 +102,26 @@ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. - `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. -Three concrete generators currently live in-module: +Five concrete generators currently live in-module: - [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. - [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `gen/.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 fallthrough. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. - [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `gen/.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). 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 `gen/.report` per report. Dimensions become columns with `aggregate: NONE`; measures are parsed by the `aggregate(field)` convention (`count(*)`, `sum(total)`, `avg(price)`, `min(...)`, `max(...)`) into columns with the matching aggregate. `baseTable` is the upper-snake of the report's `source` entity name so it lines up with what the EDM generator emits as `dataName`. `query` / `joins` / `filters` / `orders` are left empty - the report editor builds the SQL on open. +- [`PermissionIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java) writes `gen/.roles` from the intent's `permissions` block (deduped by role name). It deliberately does NOT emit `.access` constraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. The `can: [Resource:action, ...]` tokens on each permission are an authoring hint to downstream UI generators about which actions each role may invoke; the actual `` mapping is the downstream template's contract, not intent's. -Together they are the worked examples; additional slice generators follow the same pattern. - -The remaining generators each map one intent block to one model-layer extension: +Together they cover every intent block defined today. | Intent block | Output | Spring `@Order` | |---|---|---| | `entities` | `gen/.edm` + `gen/.model` | 200 (done) | | `processes[]` | `gen/.bpmn` (one per process) | 300 (done) | | `forms[]` | `gen/.form` | 400 (done) | -| `reports[]` | `gen/.report` | 500 | -| `permissions` | `gen/.roles` + `gen/.access` | 600 | +| `reports[]` | `gen/.report` | 500 (done) | +| `permissions` | `gen/.roles` | 600 (done) | | (future) `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 | | (future) low-level `schemas[]` | `gen/.dsm` + `gen/.schema` | 250 | +| (future) custom-action `.access` rules | `gen/.access` | 650 | All implementations are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order`. Leave gaps of 100 so future generators can slot between. @@ -221,7 +224,8 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci ## Follow-ups -- Remaining model-layer generators: `.form`, `.report`, `.roles` + `.access` (permissions). DSM / schema / table / view / csvim / csv are lower-priority because the EDM the first generator writes already covers the same surface implicitly. +- `.access` rules from intent. The current PermissionIntentGenerator deliberately emits only `.roles`; URL-shaped constraints (the `` table in `.access`) need to know the paths the downstream template engine will publish, so they live with that template. A future pass should either (a) wire intent to feed those paths into the EDM template generator so it can emit the matching `.access`, or (b) add a custom-action `.access` block to intent for non-CRUD operations like {@code Order:approve} where there is no template-owned URL. +- Lower-priority model-layer generators: DSM / schema / table / view / csvim / csv. The EDM-only entry already covers the same surface implicitly, so these are optional refinements rather than gaps. - Trigger the "Generate from EDM" template programmatically on intent change so the developer sees the full app, not just the model files. Today the user has to open the EDM editor and click Generate manually. - Claude chat + patch-preview in the perspective. Needs a separate LLM bridge module (Anthropic API key via `DirigibleConfig`, request shaping, structured-patch responses, accept / reject flow). Out of scope for this PR. - Read-only Monaco model for paths under `**/gen/**` so the IDE marks them not-for-editing. @@ -233,5 +237,5 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci **Done:** - Structural validation on parse: duplicate names, dangling relation / form / report targets, unknown field / relation / step kinds. Surfaced via `IntentValidationException` with the complete list of issues in one error message. -- `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form). +- All five v1 model-layer generators: `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form), `ReportIntentGenerator` (reports -> .report), `PermissionIntentGenerator` (permissions -> .roles). - End-to-end integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering the full Orders pipeline (4 entities + relations + process with every step kind + 2 forms + report + 3 permission roles) and exercising the REST endpoints. HTTP-only, no Selenide. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java new file mode 100644 index 0000000000..2ce25a1992 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java @@ -0,0 +1,117 @@ +/* + * 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.permission; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.PermissionIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits {@code gen/.roles} from the intent's {@code permissions}. The roles are deduped by + * name; the {@code can: [Resource:action, ...]} tokens are NOT translated into {@code .access} + * constraints here. URL-shaped access constraints belong to the downstream EDM / form / report + * template generators, which know the paths they will publish; emitting them from the intent layer + * would couple the engine to a specific template's output paths. + * + *

+ * The {@code permissions} block on the intent therefore plays two roles today: + *

    + *
  • It is the canonical place to declare the role names the app uses - this generator + * materializes those into {@code .roles}, which the {@code RolesSynchronizer} picks up.
  • + *
  • The {@code can} tokens are a hint to downstream UI generators about which actions each role + * may invoke. The mapping from token to URL is the downstream template's contract, not intent's. + * Until that contract is wired, the tokens are informational; document them but do not generate + * {@code .access} from them.
  • + *
+ * + *

+ * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(600) +public class PermissionIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(PermissionIntentGenerator.class); + + @Override + public String name() { + return "permission"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getPermissions() + .isEmpty()) { + return; + } + String baseName = baseName(context); + String path = context.getGenRoot() + "/" + baseName + ".roles"; + writeResource(context.getRepository(), path, buildRolesJson(model)); + } + + private static String buildRolesJson(IntentModel model) { + Set seenNames = new LinkedHashSet<>(); + java.util.List> roles = new ArrayList<>(); + for (PermissionIntent permission : model.getPermissions()) { + String name = permission.getRole(); + if (name == null || name.isBlank()) { + LOGGER.warn("Skipping permission entry with no role name"); + continue; + } + if (!seenNames.add(name)) { + continue; + } + Map entry = new LinkedHashMap<>(); + entry.put("name", name); + if (permission.getDescription() != null && !permission.getDescription() + .isBlank()) { + entry.put("description", permission.getDescription()); + } + roles.add(entry); + } + return JsonHelper.toJson(roles); + } + + private static String baseName(IntentGenerationContext context) { + String intentName = context.getIntent() + .getName(); + if (intentName != null && !intentName.isBlank()) { + return intentName; + } + String project = context.getProjectName(); + return project.isEmpty() ? "intent" : project; + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} 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 new file mode 100644 index 0000000000..d921d572ef --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -0,0 +1,278 @@ +/* + * 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.report; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.ReportIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits one {@code gen/.report} per {@link ReportIntent} declared in the intent. The output + * is the JSON shape the report editor in the IDE consumes: an outer record with {@code alias} / + * {@code tId} / {@code label} / {@code query} plus a {@code columns} array whose entries carry + * {@code name} / {@code alias} / {@code type} / {@code aggregate}. + * + *

+ * Dimensions become columns with {@code aggregate: NONE}; measures are parsed by the + * {@code count(*)} / {@code sum(field)} / {@code avg(field)} / {@code min(field)} / + * {@code max(field)} convention into columns with the matching aggregate. Any measure that doesn't + * match the pattern is logged and emitted as a {@code NONE}-aggregate column carrying the raw text + * as its name. + * + *

+ * {@code query} is left empty; the report editor builds the SQL from {@code baseTable} + + * {@code columns} + {@code joins} + {@code filters} on open, and intent doesn't yet model joins or + * filters. {@code baseTable} is the upper-snake of the report's {@code source} entity name - + * matching what the EDM generator emits as {@code dataName}, so the two artefacts line up. + * + *

+ * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(500) +public class ReportIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReportIntentGenerator.class); + + /** + * {@code aggregate(field)} pattern. Captures the aggregate name in group 1 and the field in group + * 2. + */ + private static final Pattern AGGREGATE_EXPRESSION = Pattern.compile("\\s*(\\w+)\\s*\\(\\s*([^)]*)\\s*\\)\\s*"); + + private static final Set KNOWN_AGGREGATES = Set.of("COUNT", "SUM", "AVG", "MIN", "MAX"); + + @Override + public String name() { + return "report"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getReports() + .isEmpty()) { + return; + } + IRepository repository = context.getRepository(); + String genRoot = context.getGenRoot(); + Set seenFiles = new HashSet<>(); + for (ReportIntent report : model.getReports()) { + if (report.getName() == null || report.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed report in intent [{}]", context.getIntent() + .getName()); + continue; + } + String fileName = report.getName() + ".report"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate report [{}] in intent [{}] - keeping the first occurrence", report.getName(), context.getIntent() + .getName()); + continue; + } + Map document = build(report); + writeResource(repository, genRoot + "/" + fileName, JsonHelper.toJson(document)); + } + } + + private static Map build(ReportIntent report) { + Map document = new LinkedHashMap<>(); + document.put("alias", report.getName()); + document.put("tId", translationId(report.getName())); + document.put("label", humanize(report.getName())); + if (report.getDescription() != null && !report.getDescription() + .isBlank()) { + document.put("description", report.getDescription()); + } + if (report.getSource() != null && !report.getSource() + .isBlank()) { + document.put("baseTable", toUpperSnake(report.getSource())); + } + document.put("query", ""); + document.put("columns", buildColumns(report)); + document.put("joins", new ArrayList<>()); + document.put("filters", new ArrayList<>()); + document.put("orders", new ArrayList<>()); + return document; + } + + private static List> buildColumns(ReportIntent report) { + List> columns = new ArrayList<>(); + for (String dimension : report.getDimensions()) { + if (dimension == null || dimension.isBlank()) { + continue; + } + columns.add(dimensionColumn(dimension)); + } + for (String measure : report.getMeasures()) { + if (measure == null || measure.isBlank()) { + continue; + } + columns.add(measureColumn(measure)); + } + return columns; + } + + /** + * Dimension columns carry the field path verbatim and default to a VARCHAR with no aggregate. + * Dotted paths (e.g. {@code customer.country}) are kept as-is - the report editor resolves the + * cross-table reference on open. + */ + private static Map dimensionColumn(String dimension) { + String alias = aliasOfDimension(dimension); + Map column = new LinkedHashMap<>(); + column.put("name", dimension); + column.put("alias", alias); + column.put("tId", translationId(alias)); + column.put("label", humanize(alias)); + column.put("type", "VARCHAR"); + column.put("aggregate", "NONE"); + return column; + } + + /** + * Parse a measure expression like {@code count(*)} or {@code sum(total)} into a column with the + * matching aggregate. Unknown shapes fall back to NONE-aggregate columns carrying the raw text so + * the report editor at least loads the file. + */ + private static Map measureColumn(String measure) { + Matcher matcher = AGGREGATE_EXPRESSION.matcher(measure); + if (matcher.matches()) { + String aggregate = matcher.group(1) + .toUpperCase(Locale.ROOT); + String field = matcher.group(2) + .trim(); + if (KNOWN_AGGREGATES.contains(aggregate)) { + String columnName = field.isEmpty() ? "*" : field; + String alias = aliasOfMeasure(aggregate, field); + Map column = new LinkedHashMap<>(); + column.put("name", columnName); + column.put("alias", alias); + column.put("tId", translationId(alias)); + column.put("label", humanize(alias)); + column.put("type", numericTypeFor(aggregate)); + column.put("aggregate", aggregate); + return column; + } + } + LOGGER.warn("Measure [{}] did not match the aggregate(field) convention - emitting as a NONE-aggregate raw column", measure); + Map column = new LinkedHashMap<>(); + column.put("name", measure); + column.put("alias", aliasOfDimension(measure)); + column.put("tId", translationId(measure)); + column.put("label", humanize(measure)); + column.put("type", "VARCHAR"); + column.put("aggregate", "NONE"); + return column; + } + + private static String aliasOfDimension(String dimension) { + return dimension.replace('.', '_') + .replace(' ', '_'); + } + + private static String aliasOfMeasure(String aggregate, String field) { + if (field.isEmpty() || "*".equals(field)) { + return aggregate.toLowerCase(Locale.ROOT); + } + return aggregate.toLowerCase(Locale.ROOT) + "_" + field.replace('.', '_'); + } + + /** + * COUNT yields an integer; the rest of the supported aggregates can carry decimals. + */ + private static String numericTypeFor(String aggregate) { + return "COUNT".equals(aggregate) ? "INTEGER" : "DECIMAL"; + } + + private static String translationId(String raw) { + if (raw == null) { + return ""; + } + return raw.replace(" ", "") + .replace("_", "") + .replace(".", "") + .replace(":", "") + .replace("*", "all"); + } + + /** + * camelCase / snake_case / dotted-path identifier to a human label. + */ + private static String humanize(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(raw.length() + 4); + boolean capitalizeNext = true; + for (int i = 0; i < raw.length(); i++) { + char c = raw.charAt(i); + if (c == '_' || c == '-' || c == '.') { + out.append(' '); + capitalizeNext = true; + continue; + } + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(raw.charAt(i - 1))) { + out.append(' '); + } + if (capitalizeNext) { + out.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + out.append(c); + } + } + return out.toString(); + } + + private static String toUpperSnake(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(name.length() + 8); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + out.append('_'); + } + out.append(Character.toUpperCase(c)); + } + return out.toString(); + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} 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 4bf4fe838b..48a7278f18 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 @@ -44,7 +44,8 @@ * {@code IntentModel} field defined today is exercised. The test asserts: *

    *
  • the {@link Intent} JPA artefact is persisted via {@link IntentService}
  • - *
  • the {@code /services/ide/intent/*} REST endpoints list / fetch / source / regenerate the project
  • + *
  • the {@code /services/ide/intent/*} REST endpoints list / fetch / source / regenerate the + * project
  • *
  • {@code EdmIntentGenerator} produces a {@code gen/orders.edm} + {@code gen/orders.model} pair * containing every entity, every property, and every relation
  • *
  • {@code BpmnIntentGenerator} produces a {@code gen/OrderApproval.bpmn} with the right BPMN @@ -176,6 +177,8 @@ void full_intent_pipeline_generates_all_model_files() { assertEdmAndModelGenerated(); assertBpmnGenerated(); assertFormGenerated(); + assertReportGenerated(); + assertRolesGenerated(); assertRestEndpoints(); } @@ -268,6 +271,30 @@ private void assertFormGenerated() { "customer form should map active:boolean to an input-checkbox"); } + private void assertReportGenerated() { + IResource report = repository.getResource(REGISTRY_GEN + "/OrdersByCustomer.report"); + assertTrue(report.exists(), "gen/OrdersByCustomer.report should be generated"); + String body = new String(report.getContent(), StandardCharsets.UTF_8); + assertTrue(body.contains("\"alias\":\"OrdersByCustomer\""), "report should carry the intent name as alias"); + assertTrue(body.contains("\"baseTable\":\"ORDER\""), "report should map the source entity name to its upper-snake table name"); + assertTrue(body.contains("\"aggregate\":\"NONE\""), "dimensions should be emitted with aggregate NONE"); + assertTrue(body.contains("\"aggregate\":\"COUNT\""), "count(*) should be parsed into an aggregate COUNT column"); + assertTrue(body.contains("\"aggregate\":\"SUM\""), "sum(total) should be parsed into an aggregate SUM column"); + assertTrue(body.contains("\"name\":\"customer.country\""), + "dotted dimension paths should be preserved verbatim in the column name"); + assertTrue(body.contains("\"name\":\"total\""), "sum(total) measure should resolve to a column whose name is 'total'"); + } + + private void assertRolesGenerated() { + IResource roles = repository.getResource(REGISTRY_GEN + "/orders.roles"); + assertTrue(roles.exists(), "gen/orders.roles should be generated"); + String body = new String(roles.getContent(), StandardCharsets.UTF_8); + assertTrue(body.contains("\"name\":\"Sales\""), "Sales role should be present"); + assertTrue(body.contains("\"name\":\"Manager\""), "Manager role should be present"); + assertTrue(body.contains("\"name\":\"Administrator\""), "Administrator role should be present"); + assertTrue(body.contains("\"description\":\"Sales staff\""), "Role descriptions should be carried through"); + } + private void assertRestEndpoints() { restAssuredExecutor.execute(() -> given().when() .get("/services/ide/intent/projects") From 623e7896a02cf40a04ce728ee8e2b709547c5b9e Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 11:26:31 +0300 Subject: [PATCH 08/28] Add Country reference entity + seed-data support (.csvim + .csv) Country becomes a first-class entity in the IT YAML (id/name/code2/code3/ numeric, shape borrowed from codbex/codbex-countries) and Customer's country drops from a free-text string field to a manyToOne reference relation - matching how partner-style profiles model country lookups in codbex/codbex-partners. The EDM generator already supports manyToOne relations, so the change is purely intent-side; the .edm now carries the new Customer -> Country reference + a CUSTOMER_COUNTRY FK column on Customer. SeedIntent + IntentModel.seeds[] new POJO + collection. Each seed declares a target entity and a list of rows (key/value maps keyed by the intent's field names). Validation rejects unnamed seeds, duplicate seed names, seeds with no entity, seeds targeting unknown entities, and seeds with no rows; surfaces every problem in one IntentValidationException message. CsvimIntentGenerator (@Order 700) writes two files per seed: - gen/.csvim - JSON CSVIM declaration the platform's CsvimSynchronizer consumes. Defaults match the existing platform IT fixtures (header:true, useHeaderNames:true, delimField:",", delimEnclosing:"\"", distinguishEmptyFromNull:true, version:"1.0", schema PUBLIC when the seed doesn't override it). - gen/.csv - CSV body. Header carries the entity's dataName columns (upper-snake of the field names, prefixed with the entity's dataName, e.g. COUNTRY_ID, COUNTRY_NAME). Row order matches the entity's declared field order so a row author can omit fields without misaligning the columns. Cells containing the delimiter, the quote character, or a newline are quoted and inner quotes doubled. IT YAML expanded to include the Country entity + the Customer -> Country manyToOne + a seeds block shaped after codbex/codbex-countries-data preloading three rows (Afghanistan/Albania/Algeria) into COUNTRY. The test now asserts: - the EDM declares Country and carries the new referenced="Country" link plus the CUSTOMER_COUNTRY FK column on Customer - the parsed-intent REST response carries five entities (Country included), one seeds entry, and three rows on it - gen/countries.csvim declares the COUNTRY table, PUBLIC schema, sibling CSV reference, and the platform-standard CSVIM defaults - gen/countries.csv starts with the expected COUNTRY_ID,COUNTRY_NAME, COUNTRY_CODE2,COUNTRY_CODE3,COUNTRY_NUMERIC header and carries each of the three rows CLAUDE.md updated: layout block shows the new SeedIntent POJO and the csvim/ generator package; the generator table now lists six (.csvim + .csv) as done; the YAML shape sample carries a seeds block; the done list calls out the codbex-countries / codbex-partners / codbex-countries-data inspiration that drove the IT shape. quick-build install, formatter:validate, release-profile javadoc gate and a clean compile of tests-integrations all pass. Co-Authored-By: Claude Opus 4.7 --- components/engine/engine-intent/CLAUDE.md | 26 +- .../generator/csvim/CsvimIntentGenerator.java | 227 ++++++++++++++++++ .../components/intent/model/IntentModel.java | 9 + .../components/intent/model/SeedIntent.java | 87 +++++++ .../intent/parser/IntentParser.java | 26 ++ .../integration/tests/api/IntentEngineIT.java | 88 +++++-- 6 files changed, 439 insertions(+), 24 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index ad260129f5..2bce382a2f 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -72,7 +72,7 @@ components/engine/engine-intent/ ├── repository/IntentRepository.java # Spring Data ├── service/IntentService.java # CRUD via BaseArtefactService ├── model/ # POJOs for the intent document (Gson-mapped after YAML → Map → JSON round-trip) - │ ├── IntentModel.java # root: entities / processes / forms / reports / permissions + │ ├── IntentModel.java # root: entities / processes / forms / reports / permissions / seeds │ ├── EntityIntent.java │ ├── FieldIntent.java │ ├── RelationIntent.java @@ -80,7 +80,8 @@ components/engine/engine-intent/ │ ├── StepIntent.java │ ├── FormIntent.java │ ├── ReportIntent.java - │ └── PermissionIntent.java + │ ├── PermissionIntent.java + │ └── SeedIntent.java ├── parser/ │ ├── IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (Gson) + structural validation │ └── IntentValidationException.java # collects every structural issue in one shot @@ -92,7 +93,8 @@ components/engine/engine-intent/ │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process │ ├── form/FormIntentGenerator.java # @Order(400); writes gen/.form per form (typed controls + action buttons + stub code) │ ├── report/ReportIntentGenerator.java # @Order(500); writes gen/.report per report (dimensions + parsed measures) - │ └── permission/PermissionIntentGenerator.java # @Order(600); writes gen/.roles per intent (deduped role names + descriptions) + │ ├── permission/PermissionIntentGenerator.java # @Order(600); writes gen/.roles per intent (deduped role names + descriptions) + │ └── csvim/CsvimIntentGenerator.java # @Order(700); writes gen/.csvim + gen/.csv per seed ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -102,13 +104,14 @@ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. - `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. -Five concrete generators currently live in-module: +Six concrete generators currently live in-module: - [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. - [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `gen/.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 fallthrough. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. - [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `gen/.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). 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 `gen/.report` per report. Dimensions become columns with `aggregate: NONE`; measures are parsed by the `aggregate(field)` convention (`count(*)`, `sum(total)`, `avg(price)`, `min(...)`, `max(...)`) into columns with the matching aggregate. `baseTable` is the upper-snake of the report's `source` entity name so it lines up with what the EDM generator emits as `dataName`. `query` / `joins` / `filters` / `orders` are left empty - the report editor builds the SQL on open. - [`PermissionIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java) writes `gen/.roles` from the intent's `permissions` block (deduped by role name). It deliberately does NOT emit `.access` constraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. The `can: [Resource:action, ...]` tokens on each permission are an authoring hint to downstream UI generators about which actions each role may invoke; the actual `` mapping is the downstream template's contract, not intent's. +- [`CsvimIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java) writes `gen/.csvim` + `gen/.csv` per seed. CSVIM defaults match the existing platform samples (`header: true`, `useHeaderNames: true`, field delim `,`, enclosing `"`, `version: 1.0`, schema `PUBLIC`). The CSV header carries the entity's `dataName` columns (upper-snake of the field names, prefixed with the entity's `dataName`); row order matches the entity's declared field order so row authors can omit fields without misaligning columns. Cells containing the delimiter, the quote, or a newline are quoted and inner quotes doubled. Together they cover every intent block defined today. @@ -119,7 +122,7 @@ Together they cover every intent block defined today. | `forms[]` | `gen/.form` | 400 (done) | | `reports[]` | `gen/.report` | 500 (done) | | `permissions` | `gen/.roles` | 600 (done) | -| (future) `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 | +| `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 (done) | | (future) low-level `schemas[]` | `gen/.dsm` + `gen/.schema` | 250 | | (future) custom-action `.access` rules | `gen/.access` | 650 | @@ -200,6 +203,13 @@ reports: permissions: - { role: Sales, can: [Customer:read, Customer:write, Order:create] } - { role: Manager, can: [Order:approve] } + +seeds: + - name: countries + entity: Country + rows: + - { id: 1, name: Afghanistan, code2: AF, code3: AFG, numeric: "004" } + - { id: 2, name: Albania, code2: AL, code3: ALB, numeric: "008" } ``` Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `decimal`, `boolean`, `date`, `uuid`. Generators map them to JDBC + EDM types. Relation kinds: `oneToMany`, `manyToOne`, `oneToOne`, `manyToMany`. Step kinds: `userTask`, `serviceTask`, `decision`, `script`, `end`. @@ -236,6 +246,6 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci **Done:** -- Structural validation on parse: duplicate names, dangling relation / form / report targets, unknown field / relation / step kinds. Surfaced via `IntentValidationException` with the complete list of issues in one error message. -- All five v1 model-layer generators: `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form), `ReportIntentGenerator` (reports -> .report), `PermissionIntentGenerator` (permissions -> .roles). -- End-to-end integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering the full Orders pipeline (4 entities + relations + process with every step kind + 2 forms + report + 3 permission roles) and exercising the REST endpoints. HTTP-only, no Selenide. +- Structural validation on parse: duplicate names, dangling relation / form / report / seed targets, unknown field / relation / step kinds, multi-PK and empty-seed checks. Surfaced via `IntentValidationException` with the complete list of issues in one error message. +- All six v1 model-layer generators: `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form), `ReportIntentGenerator` (reports -> .report), `PermissionIntentGenerator` (permissions -> .roles), `CsvimIntentGenerator` (seeds -> .csvim + .csv). +- End-to-end integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering the full Orders pipeline (five entities including Country reference data shaped after `codbex/codbex-countries`, Customer -> Country FK relation, process with every step kind, two forms, report, three permission roles, and a seed block shaped after `codbex/codbex-countries-data`) and exercising the REST endpoints. HTTP-only, no Selenide. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java new file mode 100644 index 0000000000..4a298f71e9 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java @@ -0,0 +1,227 @@ +/* + * 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.csvim; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.eclipse.dirigible.components.base.helpers.JsonHelper; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationContext; +import org.eclipse.dirigible.components.intent.generator.IntentTargetGenerator; +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.SeedIntent; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits two files per {@link SeedIntent}: + *
      + *
    • {@code gen/.csvim} - a CSVIM declaration the platform's {@code CsvimSynchronizer} + * consumes. References the sibling CSV file by registry-relative path.
    • + *
    • {@code gen/.csv} - the CSV body. Header row carries the entity's {@code dataName} + * columns (upper-snake of the field names), row order matches the entity's declared field order so + * row authors can omit fields and the column still maps unambiguously.
    • + *
    + * + *

    + * Defaults match the existing {@code CsvimIT} sample: {@code header: true}, + * {@code useHeaderNames: true}, {@code delimField: ","}, {@code delimEnclosing: "\""}, + * {@code distinguishEmptyFromNull: true}, {@code version: "1.0"}. Schema defaults to {@code PUBLIC} + * when the seed does not override it. + * + *

    + * Idempotent: identical input always produces byte-identical output. + */ +@Component +@Order(700) +public class CsvimIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(CsvimIntentGenerator.class); + + private static final String DEFAULT_SCHEMA = "PUBLIC"; + private static final String FIELD_DELIM = ","; + private static final String QUOTE_DELIM = "\""; + + @Override + public String name() { + return "csvim"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getSeeds() + .isEmpty()) { + return; + } + Map entitiesByName = indexEntities(model); + IRepository repository = context.getRepository(); + String genRoot = context.getGenRoot(); + Set seenFiles = new HashSet<>(); + for (SeedIntent seed : model.getSeeds()) { + if (seed.getName() == null || seed.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed seed in intent [{}]", context.getIntent() + .getName()); + continue; + } + EntityIntent entity = entitiesByName.get(seed.getEntity()); + if (entity == null) { + LOGGER.warn("Seed [{}] references unknown entity [{}] - skipping", seed.getName(), seed.getEntity()); + continue; + } + String fileName = seed.getName(); + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate seed [{}] in intent [{}] - keeping the first occurrence", seed.getName(), context.getIntent() + .getName()); + continue; + } + List orderedFields = orderedFieldsOf(entity); + String csv = renderCsv(orderedFields, entity, seed); + String csvim = renderCsvim(seed, entity, fileName); + writeResource(repository, genRoot + "/" + fileName + ".csv", csv); + writeResource(repository, genRoot + "/" + fileName + ".csvim", csvim); + } + } + + private static Map indexEntities(IntentModel model) { + Map index = new HashMap<>(); + for (EntityIntent entity : model.getEntities()) { + if (entity.getName() != null) { + index.put(entity.getName(), entity); + } + } + return index; + } + + /** + * Return the entity's fields in declaration order, skipping unnamed ones. Field order in the CSV + * header matches this list, so row authors get a predictable mapping even when they omit optional + * columns. + */ + private static List orderedFieldsOf(EntityIntent entity) { + List ordered = new ArrayList<>(); + for (FieldIntent field : entity.getFields()) { + if (field.getName() != null && !field.getName() + .isBlank()) { + ordered.add(field); + } + } + return ordered; + } + + private static String renderCsv(List fields, EntityIntent entity, SeedIntent seed) { + StringBuilder sb = new StringBuilder(256); + String entityDataName = toUpperSnake(entity.getName()); + for (int i = 0; i < fields.size(); i++) { + if (i > 0) { + sb.append(FIELD_DELIM); + } + sb.append(entityDataName) + .append('_') + .append(toUpperSnake(fields.get(i) + .getName())); + } + sb.append('\n'); + for (Map row : seed.getRows()) { + for (int i = 0; i < fields.size(); i++) { + if (i > 0) { + sb.append(FIELD_DELIM); + } + Object value = row.get(fields.get(i) + .getName()); + sb.append(formatCell(value)); + } + sb.append('\n'); + } + return sb.toString(); + } + + /** + * CSV cell formatter. Null becomes empty; values containing the field delimiter, the quote + * character, or a line break are quoted and inner quotes doubled. Everything else passes through as + * {@link Object#toString()}. + */ + private static String formatCell(Object value) { + if (value == null) { + return ""; + } + String s = value.toString(); + boolean needsQuote = s.contains(FIELD_DELIM) || s.contains(QUOTE_DELIM) || s.contains("\n") || s.contains("\r"); + if (!needsQuote) { + return s; + } + return QUOTE_DELIM + s.replace(QUOTE_DELIM, QUOTE_DELIM + QUOTE_DELIM) + QUOTE_DELIM; + } + + private static String renderCsvim(SeedIntent seed, EntityIntent entity, String fileName) { + Map entry = new LinkedHashMap<>(); + entry.put("table", toUpperSnake(entity.getName())); + entry.put("schema", seed.getSchema() == null || seed.getSchema() + .isBlank() ? DEFAULT_SCHEMA : seed.getSchema()); + entry.put("file", "/" + fileNameOnly(fileName) + ".csv"); + entry.put("header", true); + entry.put("useHeaderNames", true); + entry.put("delimField", FIELD_DELIM); + entry.put("delimEnclosing", QUOTE_DELIM); + entry.put("distinguishEmptyFromNull", true); + entry.put("version", "1.0"); + Map document = new LinkedHashMap<>(); + document.put("files", List.of(entry)); + return JsonHelper.toJson(document); + } + + /** + * Bare file name. Seed names should already be free of path separators, but normalize defensively. + */ + private static String fileNameOnly(String name) { + int slash = name.lastIndexOf('/'); + return slash < 0 ? name : name.substring(slash + 1); + } + + private static String toUpperSnake(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(name.length() + 8); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + out.append('_'); + } + out.append(Character.toUpperCase(c)); + } + return out.toString() + .toUpperCase(Locale.ROOT); + } + + private static void writeResource(IRepository repository, String path, String content) { + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(bytes); + } else { + repository.createResource(path, bytes); + } + } +} 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 64eae7a34a..750810476f 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 @@ -33,6 +33,7 @@ public class IntentModel { private List forms = new ArrayList<>(); private List reports = new ArrayList<>(); private List permissions = new ArrayList<>(); + private List seeds = new ArrayList<>(); public String getName() { return name; @@ -97,4 +98,12 @@ public List getPermissions() { public void setPermissions(List permissions) { this.permissions = permissions == null ? new ArrayList<>() : permissions; } + + public List getSeeds() { + return seeds; + } + + public void setSeeds(List seeds) { + this.seeds = seeds == null ? new ArrayList<>() : seeds; + } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java new file mode 100644 index 0000000000..6b4529e12c --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java @@ -0,0 +1,87 @@ +/* + * 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.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Seed-data block: a list of rows that should be loaded into the named entity's table on + * deployment. Each entry materializes into a {@code .csvim} declaration plus a matching {@code + * .csv} file under {@code gen/}. + * + *

    + * Rows are authored as structured maps keyed by the intent's field names (e.g. {@code id}, + * {@code name}). The generator translates field names to the corresponding {@code dataName} columns + * and writes the CSV in the entity's declared field order, so the result lines up with the table + * the EDM generator produced. + */ +public class SeedIntent { + + private String name; + private String entity; + private String schema; + private String description; + private List> rows = new ArrayList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEntity() { + return entity; + } + + public void setEntity(String entity) { + this.entity = entity; + } + + /** + * Optional database schema for the {@code .csvim} declaration. Defaults to {@code PUBLIC} when + * omitted - matches the existing CSVIM sample artefacts in the platform. + */ + public String getSchema() { + return schema; + } + + public void setSchema(String schema) { + this.schema = schema; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List> getRows() { + return rows; + } + + public void setRows(List> rows) { + this.rows = rows == null ? new ArrayList<>() : rows; + } + + /** + * Convenience for callers that want to add rows programmatically rather than via direct list + * mutation. + */ + public void addRow(Map row) { + this.rows.add(row == null ? new LinkedHashMap<>() : row); + } +} 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 17f710ea83..fe3064d8dd 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 @@ -23,6 +23,7 @@ import org.eclipse.dirigible.components.intent.model.ProcessIntent; import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.eclipse.dirigible.components.intent.model.ReportIntent; +import org.eclipse.dirigible.components.intent.model.SeedIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; import org.yaml.snakeyaml.LoaderOptions; import org.yaml.snakeyaml.Yaml; @@ -88,6 +89,7 @@ private static void validate(IntentModel model) { validateProcesses(model, issues); validateForms(model, entityNames, issues); validateReports(model, entityNames, issues); + validateSeeds(model, entityNames, issues); if (!issues.isEmpty()) { throw new IntentValidationException(issues); } @@ -220,4 +222,28 @@ private static void validateReports(IntentModel model, Set entityNames, } } } + + private static void validateSeeds(IntentModel model, Set entityNames, List issues) { + Set seedNames = new HashSet<>(); + for (SeedIntent seed : model.getSeeds()) { + if (seed.getName() == null || seed.getName() + .isBlank()) { + issues.add("seed has no name"); + continue; + } + if (!seedNames.add(seed.getName())) { + issues.add("duplicate seed [" + seed.getName() + "]"); + } + if (seed.getEntity() == null || seed.getEntity() + .isBlank()) { + issues.add("seed [" + seed.getName() + "] has no entity"); + } else if (!entityNames.contains(seed.getEntity())) { + issues.add("seed [" + seed.getName() + "] targets unknown entity [" + seed.getEntity() + "]"); + } + if (seed.getRows() + .isEmpty()) { + issues.add("seed [" + seed.getName() + "] has no rows"); + } + } + } } 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 48a7278f18..124acc6d59 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 @@ -38,20 +38,29 @@ * into the registry, triggers reconciliation, and asserts the full intent -> model-file pipeline. * *

    - * The intent declares four entities (Customer / Product / Order / OrderItem) with relations in both - * directions, an OrderApproval process with all step kinds (userTask / decision / serviceTask / - * end), two forms bound to entities, a report, and three permission roles. Every - * {@code IntentModel} field defined today is exercised. The test asserts: + * The intent declares five entities (Country / Customer / Product / Order / OrderItem) with + * relations in both directions (including Customer -> Country reference data shaped after + * {@code codbex/codbex-countries}), an OrderApproval process with every step kind (userTask / + * decision / serviceTask / end), two forms bound to entities, a report, three permission roles, and + * a seed block that preloads three rows into {@code COUNTRY} a-la + * {@code codbex/codbex-countries-data}. Every {@code IntentModel} field defined today is exercised. + * The test asserts: *

      *
    • the {@link Intent} JPA artefact is persisted via {@link IntentService}
    • *
    • the {@code /services/ide/intent/*} REST endpoints list / fetch / source / regenerate the * project
    • *
    • {@code EdmIntentGenerator} produces a {@code gen/orders.edm} + {@code gen/orders.model} pair - * containing every entity, every property, and every relation
    • + * containing every entity (Country included), every property, and every relation (Customer -> + * Country and Order -> Customer are both wired as references rather than free-text strings) *
    • {@code BpmnIntentGenerator} produces a {@code gen/OrderApproval.bpmn} with the right BPMN * elements for each step kind and the conditioned outgoing flow on the decision
    • *
    • {@code FormIntentGenerator} produces a {@code gen/.form} per form with controls typed * from the bound entity's fields and action buttons
    • + *
    • {@code ReportIntentGenerator} parses {@code aggregate(field)} measure expressions into + * columns with the right aggregate
    • + *
    • {@code PermissionIntentGenerator} emits the deduped {@code .roles} file
    • + *
    • {@code CsvimIntentGenerator} emits {@code gen/countries.csvim} + {@code gen/countries.csv} + * with the rows in the entity's declared field order
    • *
    * *

    @@ -70,16 +79,25 @@ class IntentEngineIT extends IntegrationTest { version: 1 entities: + - name: Country + description: ISO 3166-1 country reference data (shape borrowed from codbex/codbex-countries) + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 100 } + - { name: code2, type: string, length: 2 } + - { name: code3, type: string, length: 3 } + - { name: numeric, type: string, length: 3 } + - name: Customer - description: Buyer account + description: Buyer account (Partner-style profile, see codbex/codbex-partners) fields: - - { name: id, type: uuid, primaryKey: true, generated: true } - - { name: name, type: string, required: true, length: 200 } - - { name: email, type: string, length: 200 } - - { name: country, type: string, length: 2 } - - { name: active, type: boolean, defaultValue: "true" } + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: email, type: string, length: 200 } + - { name: active, type: boolean, defaultValue: "true" } relations: - - { name: orders, kind: oneToMany, to: Order } + - { name: country, kind: manyToOne, to: Country } + - { name: orders, kind: oneToMany, to: Order } - name: Product description: Product catalog entry @@ -154,6 +172,15 @@ class IntentEngineIT extends IntegrationTest { - { role: Sales, description: Sales staff, can: [Customer:read, Customer:write, Order:read, Order:create] } - { role: Manager, description: Sales manager, can: [Order:approve, Order:read] } - { role: Administrator, description: System admin, can: [Customer:write, Product:write, Order:write] } + + seeds: + - name: countries + entity: Country + description: Sample ISO 3166-1 rows (shape borrowed from codbex/codbex-countries-data) + rows: + - { id: 1, name: Afghanistan, code2: AF, code3: AFG, numeric: "004" } + - { id: 2, name: Albania, code2: AL, code3: ALB, numeric: "008" } + - { id: 3, name: Algeria, code2: DZ, code3: DZA, numeric: "012" } """; @Autowired @@ -179,6 +206,7 @@ void full_intent_pipeline_generates_all_model_files() { assertFormGenerated(); assertReportGenerated(); assertRolesGenerated(); + assertSeedsGenerated(); assertRestEndpoints(); } @@ -214,7 +242,7 @@ private void assertEdmAndModelGenerated() { IResource edm = repository.getResource(REGISTRY_GEN + "/orders.edm"); assertTrue(edm.exists(), "gen/orders.edm should be generated"); String edmXml = new String(edm.getContent(), StandardCharsets.UTF_8); - for (String entityName : List.of("Customer", "Product", "Order", "OrderItem")) { + for (String entityName : List.of("Country", "Customer", "Product", "Order", "OrderItem")) { assertTrue(edmXml.contains("name=\"" + entityName + "\""), "EDM should declare entity [" + entityName + "]"); } assertTrue(edmXml.contains("dataPrimaryKey=\"true\""), "EDM should mark at least one property as primary key"); @@ -223,10 +251,14 @@ private void assertEdmAndModelGenerated() { assertTrue(edmXml.contains("widgetType=\"CHECKBOX\""), "EDM should map the boolean field to a CHECKBOX widget"); assertTrue(edmXml.contains("widgetType=\"TEXTAREA\""), "EDM should map the text field to a TEXTAREA widget"); assertTrue(edmXml.contains("type=\"PRIMARY\""), "EDM should declare at least one PRIMARY entity"); - assertTrue(edmXml.contains("type=\"DEPENDENT\""), "EDM should mark Order/OrderItem as DEPENDENT through the manyToOne edges"); + assertTrue(edmXml.contains("type=\"DEPENDENT\""), + "EDM should mark Customer/Order/OrderItem as DEPENDENT through the manyToOne edges"); + assertTrue(edmXml.contains("referenced=\"Country\""), "EDM should carry the Customer->Country reference relation"); assertTrue(edmXml.contains("referenced=\"Customer\""), "EDM should carry the Order->Customer relation"); assertTrue(edmXml.contains("referenced=\"Order\""), "EDM should carry the OrderItem->Order relation"); assertTrue(edmXml.contains("referenced=\"Product\""), "EDM should carry the OrderItem->Product relation"); + assertTrue(edmXml.contains("dataName=\"CUSTOMER_COUNTRY\""), + "Customer->Country FK should materialize as a CUSTOMER_COUNTRY column on Customer"); IResource modelJson = repository.getResource(REGISTRY_GEN + "/orders.model"); assertTrue(modelJson.exists(), "gen/orders.model should be generated"); @@ -285,6 +317,26 @@ private void assertReportGenerated() { assertTrue(body.contains("\"name\":\"total\""), "sum(total) measure should resolve to a column whose name is 'total'"); } + private void assertSeedsGenerated() { + IResource csvim = repository.getResource(REGISTRY_GEN + "/countries.csvim"); + assertTrue(csvim.exists(), "gen/countries.csvim should be generated"); + String csvimBody = new String(csvim.getContent(), StandardCharsets.UTF_8); + assertTrue(csvimBody.contains("\"table\":\"COUNTRY\""), "csvim should declare the COUNTRY table"); + assertTrue(csvimBody.contains("\"schema\":\"PUBLIC\""), "csvim should default the schema to PUBLIC"); + assertTrue(csvimBody.contains("\"file\":\"/countries.csv\""), "csvim should point at the sibling csv"); + assertTrue(csvimBody.contains("\"header\":true"), "csvim should declare a header row"); + assertTrue(csvimBody.contains("\"useHeaderNames\":true"), "csvim should use header names for column mapping"); + + IResource csv = repository.getResource(REGISTRY_GEN + "/countries.csv"); + assertTrue(csv.exists(), "gen/countries.csv should be generated"); + String csvBody = new String(csv.getContent(), StandardCharsets.UTF_8); + assertTrue(csvBody.startsWith("COUNTRY_ID,COUNTRY_NAME,COUNTRY_CODE2,COUNTRY_CODE3,COUNTRY_NUMERIC"), + "csv header should carry the upper-snake column names in entity-field order"); + assertTrue(csvBody.contains("1,Afghanistan,AF,AFG,004"), "csv should include the Afghanistan row"); + assertTrue(csvBody.contains("2,Albania,AL,ALB,008"), "csv should include the Albania row"); + assertTrue(csvBody.contains("3,Algeria,DZ,DZA,012"), "csv should include the Algeria row"); + } + private void assertRolesGenerated() { IResource roles = repository.getResource(REGISTRY_GEN + "/orders.roles"); assertTrue(roles.exists(), "gen/orders.roles should be generated"); @@ -306,7 +358,8 @@ private void assertRestEndpoints() { .get("/services/ide/intent/projects/" + PROJECT) .then() .statusCode(200) - .body("entities", hasSize(greaterThanOrEqualTo(4))) + .body("entities", hasSize(greaterThanOrEqualTo(5))) + .body("entities.name", hasItem("Country")) .body("entities.name", hasItem("Customer")) .body("entities.name", hasItem("OrderItem")) .body("processes", hasSize(1)) @@ -314,7 +367,10 @@ private void assertRestEndpoints() { .body("processes[0].steps", hasSize(5)) .body("forms", hasSize(2)) .body("reports", hasSize(1)) - .body("permissions", hasSize(3))); + .body("permissions", hasSize(3)) + .body("seeds", hasSize(1)) + .body("seeds[0].entity", equalTo("Country")) + .body("seeds[0].rows", hasSize(3))); restAssuredExecutor.execute(() -> given().when() .get("/services/ide/intent/projects/" + PROJECT + "/source") From ae6f3061b42d36bbbe9bd902022a66db7dd27986 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 14:39:40 +0300 Subject: [PATCH 09/28] Make forceProcessSynchronizers wait until a pass actually runs processSynchronizers() silently skips when the runtime is not prepared yet or another synchronization (e.g. the scheduled SynchronizationJob) is mid-run. Callers of the force variant - tests and the IDE publish flow - rely on the registry being reconciled when it returns, so the silent skip was a race: LocalNativeAppLifecycleIT and IntentEngineIT intermittently queried artefacts a skipped pass never persisted. The force variant now retries (100ms steps, bounded at 5 minutes) until the force flag has been consumed by a completed pass and no concurrent run is still in progress. Co-Authored-By: Claude Fable 5 --- .../SynchronizationProcessor.java | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/components/core/core-initializers/src/main/java/org/eclipse/dirigible/components/initializers/synchronizer/SynchronizationProcessor.java b/components/core/core-initializers/src/main/java/org/eclipse/dirigible/components/initializers/synchronizer/SynchronizationProcessor.java index 4d43f56dce..0e9aa6f72c 100644 --- a/components/core/core-initializers/src/main/java/org/eclipse/dirigible/components/initializers/synchronizer/SynchronizationProcessor.java +++ b/components/core/core-initializers/src/main/java/org/eclipse/dirigible/components/initializers/synchronizer/SynchronizationProcessor.java @@ -142,12 +142,47 @@ public String getRegistryFolder() { return internalRegistryPath; } + /** How long {@link #forceProcessSynchronizers()} waits for a full pass to run after the force. */ + private static final long FORCE_SYNC_TIMEOUT_MILLIS = 5 * 60 * 1000L; + + /** Pause between retries while another synchronization run holds the processing slot. */ + private static final long FORCE_SYNC_RETRY_MILLIS = 100L; + /** - * Force process synchronizers. + * Force process synchronizers and wait (bounded) until a full pass has actually run. + * + *

    + * {@link #processSynchronizers()} silently skips when the runtime is not prepared yet or when + * another synchronization (e.g. the scheduled {@code SynchronizationJob}) is mid-run. Callers of + * the force variant - tests and the IDE publish flow - rely on the registry state being + * reconciled when this method returns, so a silent skip is a race: the caller immediately queries + * for an artefact the skipped pass never persisted. This method therefore retries until the force + * flag has been consumed by a completed pass (ours or a concurrent one) and no run is still in + * progress, up to {@link #FORCE_SYNC_TIMEOUT_MILLIS}. */ public void forceProcessSynchronizers() { this.synchronizationWatcher.force(); - processSynchronizers(); + long deadline = System.currentTimeMillis() + FORCE_SYNC_TIMEOUT_MILLIS; + while (true) { + processSynchronizers(); + if (!synchronizationWatcher.isModified() && !isSynchronizationRunning()) { + return; + } + if (System.currentTimeMillis() >= deadline) { + logger.warn("Forced synchronization did not complete within [{}] ms - modified: [{}], running: [{}]", + FORCE_SYNC_TIMEOUT_MILLIS, synchronizationWatcher.isModified(), isSynchronizationRunning()); + return; + } + logger.debug("Forced synchronization is waiting for a concurrent run to finish..."); + try { + Thread.sleep(FORCE_SYNC_RETRY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread() + .interrupt(); + logger.warn("Forced synchronization wait interrupted", e); + return; + } + } } /** From ef8aeee6a7a80c07756d5e86f15d3942c51a1c9b Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 12 Jun 2026 14:40:02 +0300 Subject: [PATCH 10/28] engine-intent: fix the broken pipeline end-to-end and align with platform conventions The review of the scaffold found the committed IntentEngineIT could not pass; running it locally confirmed and surfaced three pipeline-killing bugs plus a set of wrong assumptions. All fixed and the IT (now three tests) passes locally: - Registry prefix: artefact locations are registry-relative but IRepository paths are repository-absolute; generated output landed outside /registry/public where neither the IT nor any downstream synchronizer could see it. resolveProjectRoot now prepends IRepositoryStructure.PATH_REGISTRY_PUBLIC. - Empty-model parse: IntentParser mapped the YAML through JsonHelper, whose Gson is configured with excludeFieldsWithoutExposeAnnotation(); every un-annotated POJO field came back null and all six generators silently skipped. The parser now uses a plain Gson with LONG_OR_DOUBLE numbers (seed id: 1 stays "1" in CSV, not "1.0"). - Output location: model files are written at the project root next to app.intent (the layout of real-world codbex application projects and every platform fixture, and the only one the model-to-code template flow is proven to handle) - never under gen/, which the templates wipe on every regeneration. Writes go through the single writeModelFile surface; a post-pass scrub removes model files no longer backed by the intent and cleans them up when the .intent itself is deleted. - Naming: the YAML name: field drives output base names and the physical table prefix (_, e.g. ORDERS_ORDER) shared via IntentNaming across .edm dataName, .report baseTable and .csvim table - avoiding SQL reserved words and cross-project collisions. - CSVIM file paths are project-qualified (/orders/countries.csv) as CsvimProcessor resolves them against /registry/public. - EDM fidelity: required to-one relations are compositions (DEPENDENT + inherited transitive perspective, relationship* attributes on the FK property), optional ones plain associations; dropdown key/value and referencedProperty derive from the target entity's actual PK and name-like fields; the .model JSON carries perspectives/navigations and no relations array, matching editor-written documents. - Decision steps support else (gateway-default flow target) so the conditioned branch is actually skippable; then/else targets are validated at parse time; declared-but-unconsumed triggers log a warning. - INTENT_CONTENT uses the portable TEXT column definition (CLOB is not valid on PostgreSQL); mermaid is bundled as the org.webjars.npm webjar instead of loading from a CDN. CLAUDE.md is updated to match reality: the corrected path conventions (wrong turn #3), the Gson pitfall, the scrub ownership contract, the naming and decision semantics, the reference project layout, and the .gen descriptor as the future hook for programmatic model-to-code generation. Co-Authored-By: Claude Fable 5 --- components/engine/engine-intent/CLAUDE.md | 114 ++++-- .../components/intent/domain/Intent.java | 9 +- .../generator/IntentGenerationContext.java | 86 ++-- .../intent/generator/IntentNaming.java | 80 ++++ .../generator/IntentRegenerationService.java | 92 ++++- .../generator/IntentTargetGenerator.java | 2 +- .../generator/bpmn/BpmnIntentGenerator.java | 80 ++-- .../generator/csvim/CsvimIntentGenerator.java | 72 ++-- .../generator/edm/EdmIntentGenerator.java | 382 ++++++++++++------ .../generator/form/FormIntentGenerator.java | 21 +- .../permission/PermissionIntentGenerator.java | 30 +- .../report/ReportIntentGenerator.java | 43 +- .../intent/parser/IntentParser.java | 71 +++- .../synchronizer/IntentSynchronizer.java | 18 +- components/ui/view-intent-mermaid/pom.xml | 16 + .../view-intent-mermaid/intent-mermaid.html | 2 +- pom.xml | 1 + .../integration/tests/api/IntentEngineIT.java | 149 ++++--- 18 files changed, 831 insertions(+), 437 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentNaming.java diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 2bce382a2f..31cead460a 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -9,17 +9,26 @@ The whole feature lives in `org.eclipse.dirigible.components.intent.*`. ``` app.intent (YAML, author-driven by Claude / human / structured panel) ↓ Intent generators (this engine) -gen/.edm + gen/.model ← entities + relations + UI metadata -gen/.bpmn ← processes -gen/.form ← forms -gen/.report ← reports -gen/.roles + gen/.access ← permissions -gen/.csvim + gen/.csv ← seed data (future) -gen/.dsm + gen/.schema ← low-level data structures (future) +.edm + .model ← entities + relations + UI metadata (at the project root) +.bpmn ← processes +.form ← forms +.report ← reports +.roles + .access ← permissions +.csvim + .csv ← seed data +.dsm + .schema ← low-level data structures (future) ↓ Existing Dirigible template engine + per-artefact synchronizers -[Hibernate-mapped tables, generated TS / HTML / Java / SQL artefacts under gen//...] +[Hibernate-mapped tables, generated TS / HTML / Java / SQL artefacts under gen/...] ``` +**Why the project root and not `gen/`:** the model-to-code templates ("Generate from EDM") treat +`gen/` as their exclusive output folder and **wipe it on every regeneration** - intent output placed +there would be destroyed the first time the user generates the application code. The project root is +where every platform fixture keeps hand-authored model files and the only location the downstream +template flow is proven to handle; a dedicated `models/` subfolder is a future refinement once the +templates' handling of model files in subfolders is verified. The folders layer as: `app.intent` +(authored) + root model files (intent-owned, scrubbed by this engine) → `gen/` (template-owned, +wiped by the template engine) → `custom/` (hand-written escape hatch, touched by nobody). + Intent generators stop at the **model file**. They never emit `Entity.ts`, `Controller.ts`, `Repository.ts`, HTML, Java, or SQL directly - those come from the IDE's existing "Generate from EDM / Schema / BPMN" templates, fed by the model files this engine wrote. That contract is non-negotiable; see "Wrong turns we already made" below. ## Design context (what we agreed) @@ -30,7 +39,7 @@ Distilled from the chat that produced the initial scaffold. Read this BEFORE des Dirigible is already model-driven (the synchronizer model = "declarations on disk → running app"). Adding an intent layer above EDM/BPMN/form/DSM is the natural next abstraction. The second half of the pipeline (intent → standard models → generated app) reuses what already exists: project templates, decorator-driven scaffolding, the TS/Java SDKs. No new runtime concept - just a new authoring layer above the existing ones. -The dream is "no code, no modelling - just prompt": user describes what they want in natural language to Claude (or any LLM); Claude proposes a patch to `.intent`; the user accepts; the synchronizer regenerates the whole app under `gen/`; Mermaid renders the intent for a quick read-only visual. +The dream is "no code, no modelling - just prompt": user describes what they want in natural language to Claude (or any LLM); Claude proposes a patch to `.intent`; the user accepts; the synchronizer regenerates the model files at the project root, and the template engine turns them into the app under `gen/`; Mermaid renders the intent for a quick read-only visual. ### Three things any non-trivial change here must reckon with @@ -43,10 +52,10 @@ The dream is "no code, no modelling - just prompt": user describes what they wan ### Concrete agreements - **Intent generators target the model layer ONLY.** Output extensions are restricted to `.edm` / `.model` / `.bpmn` / `.form` / `.report` / `.roles` / `.access` / `.dsm` / `.schema` / `.table` / `.view` / `.csvim` / `.csv`. Anything code-shaped (`Entity.ts`, `Controller.ts`, `Repository.ts`, `*.java`, `*.html`, `*.sql`) is the **template engine's** output and must not appear in any intent generator. If you find yourself emitting code, you are at the wrong altitude. -- **YAML, not JSON.** Optimised for human authoring (comments, multi-line strings, no quote noise, friendlier LLM diffs). Parsed via SnakeYAML's `SafeConstructor` (already on the classpath transitively via Spring Boot) then round-tripped through Gson to land in the typed POJOs - one mapping path for both surfaces. +- **YAML, not JSON.** Optimised for human authoring (comments, multi-line strings, no quote noise, friendlier LLM diffs). Parsed via SnakeYAML's `SafeConstructor` (already on the classpath transitively via Spring Boot) then round-tripped through a **plain Gson** instance to land in the typed POJOs. NOT through the platform's `JsonHelper`/`GsonHelper`: those are configured with `excludeFieldsWithoutExposeAnnotation()`, which silently maps every un-annotated POJO field to null - the parse "succeeds" with an empty model and every generator quietly skips (this bug shipped once; the IT only caught it because it asserts file existence). The parser's Gson also sets `ToNumberPolicy.LONG_OR_DOUBLE` so YAML integers in seed rows stay integral (`id: 1` -> CSV `1`, not `1.0`). `JsonHelper` remains fine for the generators' Map-shaped output (maps are not field-reflected). - **Safe YAML loading is non-negotiable.** `IntentParser` constructs SnakeYAML with `SafeConstructor`, which blocks `!!type` / `!!new` tags. Intents arrive from LLM output and human paste; YAML deserialisation must never become a code-execution surface. Do not swap to `Constructor` for "ergonomics". - **One `.intent` file per project**, at the project root. There is no plan to support multiple intents per project - the whole model lives in one place so the LLM has the whole picture to diff against. (Re-evaluate if intents grow past ~2000 lines in practice; until then, one file.) -- **Everything else under `/gen/` is owned by the regeneration pass.** Developers must not edit `gen/`; the IDE surface should mark it read-only. The synchronizer does NOT need to enforce this - anything hand-edited there is overwritten on next intent change anyway. +- **In an intent project, model-layer files at the project root are owned by the regeneration pass; `/gen/` stays the template engine's.** Developers must not hand-edit the generated `.edm`/`.bpmn`/`.form`/... - anything hand-edited is overwritten, and files no longer backed by the intent are scrubbed on the next regeneration (so adding `app.intent` to a classic project hands ownership of its root-level model files to the intent engine - migrate them into the intent first). `gen/` keeps its existing platform meaning: the model-to-code templates' output folder, wiped by them on every regeneration. - **Existing projects without an intent stay "classic"** (hand-edit EDM/BPMN/form as before). An "intent project" is detected by the presence of `app.intent` at project root. A future `reverse-engineer intent` command can scan EDM/BPMN/form and propose an intent file to migrate; out of scope for now. - **Mermaid renders the intent for visualisation**, read-only. We do NOT build a Mermaid round-trip editor (it is a poor authoring surface). Editing is via the LLM prompt + structured panel; the existing modelers are NOT re-used for intent projects (they would let developers edit gen/ in disguise). - **Run-once-fix-it via Claude.** When something can't be expressed, the answer is to extend `.intent` (add a field to the schema, add a generator that consumes it), not to leak into gen/. @@ -57,8 +66,9 @@ These mistakes have been made and reverted. They are documented here so they are 1. **EntityIntentGenerator that wrote `gen/Entity.ts` directly.** This was at the wrong altitude - it tried to emit the `@Entity()` / `@Table()` / `@Column()` decorator-driven TS file that the platform's `EntitySynchronizer` (extension `Entity.ts`) consumes. That artefact is itself the output of "Generate from EDM" in the IDE; intent should emit the `.edm` instead and let the existing pipeline produce the TS. The generator was committed once (commit `9570405aa9`) and later deleted - do not bring it back. The replacement is [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) at `@Order(200)`. 2. **PermissionIntentGenerator that wrote `.access` constraints with URLs targeting the (also-wrong) generated `Controller.ts` paths.** Same mistake one altitude up: the access URLs were `/services/ts//gen/Controller.ts/*`, which assumed the missing TS controllers existed. The right output for permissions is the same `.roles` + `.access` artefacts but with paths reflecting whatever the EDM template emits, OR (preferred) lean on the `.edm` entity's own `generateDefaultRoles="true"` flag and let the template produce roles + access in lockstep with the generated UI. Not yet implemented; see the follow-up list. +3. **Generators that wrote `IRepository` paths without the `/registry/public` prefix.** Synchronizer artefact locations are *registry-relative* (`/orders/app.intent` - see `SynchronizationWalker.walk`, which strips the registry folder), but `IRepository` paths are *repository-absolute*. The first regeneration implementation derived the project root straight from the location and wrote `gen/` output to `/orders/gen/...` - i.e. **outside** the registry, where no IT assertion, no Registry view, and crucially no downstream synchronizer would ever see it. The whole two-stage pipeline was dead and the IT could not pass (it was committed red). `IntentRegenerationService.resolveProjectRoot` now prepends `IRepositoryStructure.PATH_REGISTRY_PUBLIC`; the same convention is visible in `SynchronizationProcessor`'s cleanup pass and `CsvimProcessor.getCsvResource`. When in doubt: locations are registry-relative, repository paths are not. -The general rule the above two violated: **intent generators must never reference paths or routes that belong to the template engine's output**, because the intent layer should be agnostic about which template is selected. +The general rule the first two violated: **intent generators must never reference paths or routes that belong to the template engine's output**, because the intent layer should be agnostic about which template is selected. ## Module layout @@ -87,14 +97,15 @@ components/engine/engine-intent/ │ └── IntentValidationException.java # collects every structural issue in one shot ├── generator/ │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) - │ ├── IntentGenerationContext.java # carries Intent + IntentModel + projectRoot + projectName + IRepository - │ ├── IntentRegenerationService.java # collects every SPI bean and runs them in @Order - │ ├── edm/EdmIntentGenerator.java # @Order(200); writes gen/.edm (XML) + gen/.model (JSON) - │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes gen/.bpmn per process - │ ├── form/FormIntentGenerator.java # @Order(400); writes gen/.form per form (typed controls + action buttons + stub code) - │ ├── report/ReportIntentGenerator.java # @Order(500); writes gen/.report per report (dimensions + parsed measures) - │ ├── permission/PermissionIntentGenerator.java # @Order(600); writes gen/.roles per intent (deduped role names + descriptions) - │ └── csvim/CsvimIntentGenerator.java # @Order(700); writes gen/.csvim + gen/.csv per seed + │ ├── IntentGenerationContext.java # carries Intent + IntentModel + project paths; writeModelFile() is the only write surface + │ ├── IntentNaming.java # shared naming: baseName, upperSnake, tableName (_) + │ ├── IntentRegenerationService.java # collects every SPI bean, runs them in @Order, scrubs stale model output + │ ├── edm/EdmIntentGenerator.java # @Order(200); writes .edm (XML) + .model (JSON) + │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); writes .bpmn per process + │ ├── form/FormIntentGenerator.java # @Order(400); writes .form per form (typed controls + action buttons + stub code) + │ ├── report/ReportIntentGenerator.java # @Order(500); writes .report per report (dimensions + parsed measures) + │ ├── permission/PermissionIntentGenerator.java # @Order(600); writes .roles per intent (deduped role names + descriptions) + │ └── csvim/CsvimIntentGenerator.java # @Order(700); writes .csvim + .csv per seed ├── synchronizer/IntentSynchronizer.java # BaseSynchronizer; regen pass in finishing() └── endpoint/IntentEndpoint.java # /services/ide/intent/* - list projects, fetch parsed intent, fetch raw YAML, force regenerate ``` @@ -102,36 +113,36 @@ components/engine/engine-intent/ The IDE perspective lives in two sibling UI modules: - `components/ui/perspective-intent` - perspective shell (id `intent`, order 1020, icon a three-node graph SVG). Default region `center`, view `intent-mermaid`. -- `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Loads `mermaid@11` from `cdn.jsdelivr.net` (matches the unicons pattern in the rest of the IDE). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. +- `components/ui/view-intent-mermaid` - read-only Mermaid ER renderer + toolbar (project picker, reload, regenerate, source / diagram toggle). Mermaid is bundled as the `org.webjars.npm:mermaid` dependency and loaded from `/webjars/mermaid/dist/mermaid.min.js` - the platform pattern for third-party frontend libraries (NOT a CDN: nothing else in the IDE loads off-host, and air-gapped deployments must keep working). Server returns parsed `IntentModel` JSON; the view converts to `erDiagram` spec client-side. Six concrete generators currently live in-module: -- [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `gen/.edm` (XML) plus `gen/.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. -- [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `gen/.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 fallthrough. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. -- [`FormIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java) writes one `gen/.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). 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 `gen/.report` per report. Dimensions become columns with `aggregate: NONE`; measures are parsed by the `aggregate(field)` convention (`count(*)`, `sum(total)`, `avg(price)`, `min(...)`, `max(...)`) into columns with the matching aggregate. `baseTable` is the upper-snake of the report's `source` entity name so it lines up with what the EDM generator emits as `dataName`. `query` / `joins` / `filters` / `orders` are left empty - the report editor builds the SQL on open. -- [`PermissionIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java) writes `gen/.roles` from the intent's `permissions` block (deduped by role name). It deliberately does NOT emit `.access` constraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. The `can: [Resource:action, ...]` tokens on each permission are an authoring hint to downstream UI generators about which actions each role may invoke; the actual `` mapping is the downstream template's contract, not intent's. -- [`CsvimIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java) writes `gen/.csvim` + `gen/.csv` per seed. CSVIM defaults match the existing platform samples (`header: true`, `useHeaderNames: true`, field delim `,`, enclosing `"`, `version: 1.0`, schema `PUBLIC`). The CSV header carries the entity's `dataName` columns (upper-snake of the field names, prefixed with the entity's `dataName`); row order matches the entity's declared field order so row authors can omit fields without misaligning columns. Cells containing the delimiter, the quote, or a newline are quoted and inner quotes doubled. +- [`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 mirrored from real editor-written documents (`DependsOnScenariosTestProject/sales-order.edm` is the reference): `dataName` is intent-prefixed (`ORDERS_COUNTRY`); a `required` to-one relation is a **composition** (FK property carries the `relationship*` attributes, the owner becomes DEPENDENT/MANAGE_DETAILS and inherits the - transitively resolved - parent perspective) while an optional one is a plain association DROPDOWN; dropdown key/value and `referencedProperty` come from the target entity's actual PK and `name`-like fields; the `.model` JSON carries `entities`/`perspectives`/`navigations` (no `relations` key - relations are XML-only, interleaved with their owning ``). +- [`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 is parsed but not consumed yet - a warning is logged. **No `bpmndi` diagram block** - Flowable runs without it and the BPMN editor auto-lays out on first edit, which keeps the output deterministic and avoids x/y churn between regenerations. +- [`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). 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. Dimensions become columns with `aggregate: NONE`; measures are parsed by the `aggregate(field)` convention (`count(*)`, `sum(total)`, `avg(price)`, `min(...)`, `max(...)`) into columns with the matching aggregate. `baseTable` comes from `IntentNaming.tableName` - the same intent-prefixed name the EDM declares as `dataName`, so the two can never drift. `query` / `joins` / `filters` / `orders` are left empty - the report editor builds the SQL on open. +- [`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). It deliberately does NOT emit `.access` constraints - URL-shaped access rules belong to whichever downstream template materializes the UI for an entity / form / report, because only that template knows the paths it will publish. The `can: [Resource:action, ...]` tokens on each permission are an authoring hint to downstream UI generators about which actions each role may invoke; the actual `` mapping is the downstream template's contract, not intent's. +- [`CsvimIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java) writes `.csvim` + `.csv` per seed. The `table` is `IntentNaming.tableName` (same as the EDM `dataName`); the `file` path is **project-qualified** (`//.csv`) because `CsvimProcessor` resolves it against `/registry/public`. CSVIM defaults match the existing platform samples (`header: true`, `useHeaderNames: true`, field delim `,`, enclosing `"`, `version: 1.0`, schema `PUBLIC`). The CSV header carries `_` upper-snake column names; row order matches the entity's declared field order so row authors can omit fields without misaligning columns. Cells containing the delimiter, the quote, or a newline are quoted and inner quotes doubled. Note the target table only exists after the downstream "Generate from EDM" output is published - until then the CSVIM import is retried by its own synchronizer. Together they cover every intent block defined today. | Intent block | Output | Spring `@Order` | |---|---|---| -| `entities` | `gen/.edm` + `gen/.model` | 200 (done) | -| `processes[]` | `gen/.bpmn` (one per process) | 300 (done) | -| `forms[]` | `gen/.form` | 400 (done) | -| `reports[]` | `gen/.report` | 500 (done) | -| `permissions` | `gen/.roles` | 600 (done) | -| `seeds[]` | `gen/.csvim` + `gen/.csv` | 700 (done) | -| (future) low-level `schemas[]` | `gen/.dsm` + `gen/.schema` | 250 | -| (future) custom-action `.access` rules | `gen/.access` | 650 | +| `entities` | `.edm` + `.model` | 200 (done) | +| `processes[]` | `.bpmn` (one per process) | 300 (done) | +| `forms[]` | `.form` | 400 (done) | +| `reports[]` | `.report` | 500 (done) | +| `permissions` | `.roles` | 600 (done) | +| `seeds[]` | `.csvim` + `.csv` | 700 (done) | +| (future) low-level `schemas[]` | `.dsm` + `.schema` | 250 | +| (future) custom-action `.access` rules | `.access` | 650 | All implementations are Spring `@Component` beans implementing `IntentTargetGenerator`; ordering via `@Order`. Leave gaps of 100 so future generators can slot between. ## Wiring - **Artefact type string `intent`, file extension `.intent`, JPA table `DIRIGIBLE_INTENTS`.** -- **`SynchronizersOrder.INTENT = 5`** - lower than every other artefact (EXTENSIONPOINT = 10 is the previous floor) so the intent's regenerated `gen/` files are on disk before any other synchronizer starts the NEXT cycle. +- **`SynchronizersOrder.INTENT = 5`** - lower than every other artefact (EXTENSIONPOINT = 10 is the previous floor) so the intent's regenerated model files are on disk before any other synchronizer starts the NEXT cycle. - **`IntentSynchronizer extends BaseSynchronizer`** - single-tenant. Intent itself carries no runtime state; downstream synchronizers handle their own tenancy. - **Module registered in:** - `components/pom.xml` (Maven reactor) @@ -141,11 +152,13 @@ All implementations are Spring `@Component` beans implementing `IntentTargetGene 1. `parseImpl(location, content)` - reads the YAML bytes, persists `Intent` with the raw payload in `INTENT_CONTENT`, marks the intent dirty for `finishing()`. Structural validation is deferred to `IntentParser.parse` in the regeneration pass so that a malformed YAML body doesn't block the artefact from being recorded. 2. `completeImpl(wrapper, phase)` - pure book-keeping (CREATED / UPDATED / DELETED). No runtime side-effects. -3. `finishing()` - for every intent marked dirty this cycle, calls `IntentRegenerationService.regenerate(intent)`. Each registered `IntentTargetGenerator` writes its slice under `/gen/`. Failures in one generator are logged and isolated; the others still run. +3. `finishing()` - for every intent marked dirty this cycle, calls `IntentRegenerationService.regenerate(intent)`. Each registered `IntentTargetGenerator` writes its slice through `IntentGenerationContext.writeModelFile`. Failures in one generator are logged and isolated; the others still run. +4. **Stale-output scrub.** After the generators run, `IntentRegenerationService` deletes model-layer files at the project root that the pass did not re-emit. The extension filter keeps the scrub away from `app.intent`, code files and subfolders (`gen/`, `custom/` - only direct child resources are considered). Removing a process / form / seed from the intent therefore removes its model file on the next regeneration instead of leaving a stale `.bpmn` deployed in Flowable. `cleanupImpl` runs the same scrub with an empty keep-set when the `.intent` file itself is deleted. +5. **`forceProcessSynchronizers` is reliable now.** It used to silently no-op when the scheduled `SynchronizationJob` was mid-run (a race that flaked `IntentEngineIT` and `LocalNativeAppLifecycleIT`); it now retries until a full pass has actually consumed the force flag, bounded at five minutes. Tests can write a resource, force, and immediately assert. ### Open design question: same-cycle vs next-cycle visibility -`SynchronizationProcessor` walks the repository **once per cycle**, dispatching every file to the first synchronizer whose `isAccepted` matches. Files written **during** the cycle - including everything `IntentRegenerationService` writes under `gen/` - are NOT visible to other synchronizers in the same cycle. They are picked up on the **next** reconciliation. +`SynchronizationProcessor` walks the repository **once per cycle**, dispatching every file to the first synchronizer whose `isAccepted` matches. Files written **during** the cycle - including everything `IntentRegenerationService` writes - are NOT visible to other synchronizers in the same cycle. They are picked up on the **next** reconciliation. This is acceptable for the scaffold: developer publishes, intent regenerates, second reconciliation (auto or manual) brings the rest live. UX-wise it is a half-beat behind. Real options to fix: @@ -183,7 +196,7 @@ processes: args: { assignee: manager, form: ApproveOrder } - name: bigOrder kind: decision - args: { if: "amount > 10000", then: cfoReview } + args: { if: "amount > 10000", then: cfoReview, else: end } - name: cfoReview kind: userTask args: { assignee: cfo, form: ApproveOrder } @@ -214,6 +227,14 @@ seeds: Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `decimal`, `boolean`, `date`, `uuid`. Generators map them to JDBC + EDM types. Relation kinds: `oneToMany`, `manyToOne`, `oneToOne`, `manyToMany`. Step kinds: `userTask`, `serviceTask`, `decision`, `script`, `end`. +Semantics worth knowing: + +- **`required: true` on a to-one relation means composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. An optional to-one is an association: plain dropdown, the entity keeps its own top-level perspective. An entity's *first* required to-one is its composition parent; further required relations are NOT NULL associations. +- **Decision steps**: `if` + `then` are mandatory; `else` is optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - without `else` the default falls through to the next step in the chain). `then`/`else` must name a declared step or the literal `end`; the parser validates this so a typo fails at parse time instead of producing BPMN Flowable rejects. +- **`trigger` is parsed but not consumed yet.** `BpmnIntentGenerator` logs a warning when a process declares one; wiring `onCreate`/`onSchedule` to process starts is on the follow-up list. Do not promise trigger behavior to users until that lands. +- **The YAML `name:` field is the intent's identity for outputs.** `IntentNaming.baseName` prefers it over the artefact name derived from the file name (which is conventionally just `app` from `app.intent`); single-file outputs are `.edm` / `.model` / `.roles` and the table prefix is its upper-snake. +- **Physical table names are intent-prefixed**: `_` upper-snake (`ORDERS_ORDER`), via `IntentNaming.tableName`, consistently across `.edm` `dataName`, `.report` `baseTable` and `.csvim` `table`. This avoids SQL reserved words (`ORDER`, `USER`, ...) and cross-project collisions in a shared schema. If the downstream "Generate from EDM" wizard asks for a table prefix, intent projects must leave it empty - the prefix is already part of `dataName`. + ### YAML authoring rules - **Comments are allowed and encouraged.** Lines starting with `#` survive a SnakeYAML load → JSON round-trip only as dropped content, so they are NOT preserved across regeneration of the intent itself - but since the intent is the only authored artefact and no tool ever rewrites it, comments authored by a developer stay put. The LLM patch path must respect the surrounding comments. @@ -226,7 +247,7 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci - **Don't emit code-shaped files from any intent generator.** No `*.ts`, `*.java`, `*.html`, `*.sql`, `*.css`. Output extensions are restricted to the model layer (`.edm` / `.model` / `.bpmn` / `.form` / `.report` / `.roles` / `.access` / `.dsm` / `.schema` / `.table` / `.view` / `.csvim` / `.csv`). The existing template engine produces code; the intent layer produces models. - **Don't make intent multi-tenant.** Authoring is single-tenant; generated artefacts handle their own tenancy. - **Don't let intent rewrite or sort itself.** Diff stability matters - the LLM has to produce minimal patches, which only works if the on-disk shape is stable. No auto-formatting, no field reordering. -- **Don't generate outside `gen/`.** The synchronizer relies on this to scrub stale gen/ files between cycles without risking developer-authored files. `IntentGenerationContext.getGenRoot()` is the only path generators write to. +- **Write only via `IntentGenerationContext.writeModelFile` (project root).** The post-pass scrub deletes model files that were not re-emitted through `writeModelFile` - a generator that writes through `IRepository` directly would see its output deleted right after producing it. Never write into `gen/`: the model-to-code templates wipe that folder wholesale on every regeneration. - **Don't reference template-engine output paths.** Intent generators must be ignorant of which downstream template the user will run. The `.access` constraints must not name `gen/Controller.ts` paths; either use the EDM's own `generateDefaultRoles="true"` flag (preferred) or emit role / path tokens the template engine resolves itself. - **Don't add a Mermaid editor.** Mermaid is for visualisation. Authoring is prompt + structured panel. - **Don't reuse the existing modelers for intent projects.** That would re-expose `gen/` as an authoring surface and undo the whole point. @@ -236,16 +257,21 @@ Logical field types (`FieldIntent.type`) are: `string`, `text`, `integer`, `deci - `.access` rules from intent. The current PermissionIntentGenerator deliberately emits only `.roles`; URL-shaped constraints (the `` table in `.access`) need to know the paths the downstream template engine will publish, so they live with that template. A future pass should either (a) wire intent to feed those paths into the EDM template generator so it can emit the matching `.access`, or (b) add a custom-action `.access` block to intent for non-CRUD operations like {@code Order:approve} where there is no template-owned URL. - Lower-priority model-layer generators: DSM / schema / table / view / csvim / csv. The EDM-only entry already covers the same surface implicitly, so these are optional refinements rather than gaps. -- Trigger the "Generate from EDM" template programmatically on intent change so the developer sees the full app, not just the model files. Today the user has to open the EDM editor and click Generate manually. +- Trigger the "Generate from EDM" template programmatically on intent change so the developer sees the full app, not just the model files. Today the user has to open the EDM editor and click Generate manually. **The hook is the `.gen` descriptor** that real-world codbex application projects keep next to each model file (`.gen` beside `.model`, one `.gen` per form): it records `templateId`, `filePath`, `genFolderName`, `tablePrefix`, `dataSource` and the perspective layout - exactly the parameters the IDE generation service replays. A future `GenDescriptorIntentGenerator` could emit these with `"tablePrefix": ""` baked in (the prefix already lives in the intent-prefixed `dataName`), making model-to-code generation one click or fully automatic. +- Reference layout: production codbex application projects are the canonical real-world shape this engine generates towards - model files (`.edm`/`.model`, `.bpmn`, `*.form`, `*.report`) at the project root, template output under `gen/`, hand-written BPMN service-task handlers under `tasks/` (our `custom/` concept), generated translation skeletons under `translations/`. Their `.form` files match the FormIntentGenerator output shape key-for-key (`metadata`/`feeds`/`scripts`/`code`/`form`). +- Translation skeletons (`translations//.form.json`, `.model.json`) as an additional intent generator, mirroring the production project layout. - Claude chat + patch-preview in the perspective. Needs a separate LLM bridge module (Anthropic API key via `DirigibleConfig`, request shaping, structured-patch responses, accept / reject flow). Out of scope for this PR. -- Read-only Monaco model for paths under `**/gen/**` so the IDE marks them not-for-editing. +- Read-only Monaco markers for intent-generated model files so the IDE marks them not-for-editing. - `/custom/` escape-hatch directory + per-slice hook points in the generators (the generators must learn to preserve `/custom/` files alongside their gen output). - `reverse-engineer intent` command for migrating classic projects. - Same-cycle visibility (open design question above). -- Stale-file cleanup: when an entity is removed from intent, its slot in the `.edm` should disappear too. `EdmIntentGenerator` regenerates the whole `.edm` from scratch so removal is automatic for entities; if we ever shard the EDM to one file per entity, this becomes non-trivial. +- Wire the `trigger` block: `onCreate: ` should start the process when the entity is created (listener or interceptor on the generated persistence layer), `onSchedule` should map to a timer start event or a `.job` artefact. **Done:** - Structural validation on parse: duplicate names, dangling relation / form / report / seed targets, unknown field / relation / step kinds, multi-PK and empty-seed checks. Surfaced via `IntentValidationException` with the complete list of issues in one error message. - All six v1 model-layer generators: `EdmIntentGenerator` (entities -> .edm + .model), `BpmnIntentGenerator` (processes -> .bpmn), `FormIntentGenerator` (forms -> .form), `ReportIntentGenerator` (reports -> .report), `PermissionIntentGenerator` (permissions -> .roles), `CsvimIntentGenerator` (seeds -> .csvim + .csv). -- End-to-end integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering the full Orders pipeline (five entities including Country reference data shaped after `codbex/codbex-countries`, Customer -> Country FK relation, process with every step kind, two forms, report, three permission roles, and a seed block shaped after `codbex/codbex-countries-data`) and exercising the REST endpoints. HTTP-only, no Selenide. +- Integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java) covering stage one of the Orders pipeline - intent -> persisted artefact -> generated model files (five entities, composition + association relations, process with every step kind incl. a meaningful then/else decision, two forms, report, roles, seeds), the stale-output scrub on regeneration, model-file cleanup on intent removal, and the REST endpoints. HTTP-only, no Selenide. It asserts the *shape* of the model files; pushing them through the downstream synchronizers/templates (stage two) is not covered yet. +- Stale-output scrub + gen cleanup on intent deletion (the flat-gen-root ownership contract above). +- Reliable `forceProcessSynchronizers` (bounded wait instead of silent skip) in `core-initializers`. +- Mermaid served as a webjar instead of from a CDN. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java index 7ed5e5c424..4cc215535d 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/domain/Intent.java @@ -18,7 +18,6 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Lob; import jakarta.persistence.Table; import jakarta.persistence.Transient; @@ -41,10 +40,12 @@ public class Intent extends Artefact { /** * Raw YAML payload of the {@code .intent} file, post-placeholder-expansion. Persisted so the - * regeneration step can run from the DB row without re-reading the repository. + * regeneration step can run from the DB row without re-reading the repository. The {@code TEXT} + * column definition follows the platform convention for content-bearing artefacts (Bpmn, Camel, + * OpenAPI) and is proven portable across the H2 / PostgreSQL / MSSQL CI matrix - {@code CLOB} is + * not. */ - @Lob - @Column(name = "INTENT_CONTENT", columnDefinition = "CLOB", nullable = false) + @Column(name = "INTENT_CONTENT", columnDefinition = "TEXT", nullable = false) @Expose private String content; 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 87f0102eba..7f4c35e84e 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 @@ -9,54 +9,96 @@ */ package org.eclipse.dirigible.components.intent.generator; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + import org.eclipse.dirigible.components.intent.domain.Intent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; /** * Per-regeneration call context handed to every {@link IntentTargetGenerator}. Carries the parsed - * intent, the originating artefact (for location / key metadata), the project root inside the - * Dirigible repository, and a handle to {@link IRepository} for writing files under {@code gen/}. + * intent, the originating artefact (for location / key metadata), the project paths inside the + * Dirigible repository, and the single write entry point {@link #writeModelFile(String, String)}. * *

    - * Generators are forbidden from writing anywhere other than {@code /gen/} - the - * synchronizer relies on this to scrub stale gen/ files between cycles without risking - * developer-authored files. + * Generators write model files directly at the project root (next to {@code app.intent}) - + * the location every downstream consumer is proven to handle: the platform fixtures keep + * hand-authored {@code .edm} / {@code .bpmn} / {@code .form} files there, and the model-to-code + * templates resolve their output paths relative to it. NOT under {@code gen/}: the templates wipe + * that folder wholesale on every model-to-code regeneration, so intent output placed there would + * not survive. A dedicated subfolder (e.g. {@code models/}) is a possible future refinement once + * the template engine's handling of model files in subfolders is verified. All writes go through + * {@link #writeModelFile(String, String)}, which records the emitted file names so + * {@link IntentRegenerationService} can scrub files that a previous regeneration wrote but the + * current one no longer produces. */ public final class IntentGenerationContext { - /** Repository sub-path of the project root, e.g. {@code /registry/public/orders}. */ + /** + * Full repository path of the project root, e.g. {@code /registry/public/orders}. Artefact + * locations are registry-relative, so the registry prefix is applied by + * {@link IntentRegenerationService} before this context is built. + */ private final String projectRoot; - /** Repository sub-path of the gen folder, always {@code /gen}. */ - private final String genRoot; + /** Project name - the first path segment of the intent's registry-relative location. */ + private final String projectName; private final Intent intent; private final IntentModel model; private final IRepository repository; - public IntentGenerationContext(Intent intent, IntentModel model, String projectRoot, IRepository repository) { + /** Bare file names written under {@link #projectRoot} during this regeneration pass. */ + private final Set writtenFileNames = new LinkedHashSet<>(); + + IntentGenerationContext(Intent intent, IntentModel model, String projectRoot, String projectName, IRepository repository) { this.intent = intent; this.model = model; this.projectRoot = projectRoot; - this.genRoot = projectRoot + "/gen"; + this.projectName = projectName; this.repository = repository; } /** - * Project name derived from the project root. The intent location is - * {@code //.../app.intent} inside the repository so the project name is the first - * non-empty path segment of {@link #projectRoot}. + * Write (create or overwrite) a model file at the project root. This is the only write surface + * generators may use; the emitted file name is recorded for the post-pass scrub of stale output. + * Byte-identical content is not rewritten - the synchronizer re-parses every intent each cycle, so + * an unconditional write would re-trigger the registry file watcher on every cycle and turn the + * scheduled synchronization into a perpetual no-op loop. * - * @return the project name, never null but possibly empty for malformed roots + * @param fileName bare file name including extension, e.g. {@code orders.edm} + * @param content the full file content */ - public String getProjectName() { - if (projectRoot == null || projectRoot.isEmpty()) { - return ""; + public void writeModelFile(String fileName, String content) { + String path = projectRoot + "/" + fileName; + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + IResource existing = repository.getResource(path); + if (existing.exists()) { + if (!Arrays.equals(existing.getContent(), bytes)) { + existing.setContent(bytes); + } + } else { + repository.createResource(path, bytes); } - int start = projectRoot.startsWith("/") ? 1 : 0; - int next = projectRoot.indexOf('/', start); - return next < 0 ? projectRoot.substring(start) : projectRoot.substring(start, next); + writtenFileNames.add(fileName); + } + + /** + * The bare file names emitted through {@link #writeModelFile(String, String)} so far. + * + * @return an unmodifiable view of the written file names + */ + public Set getWrittenFileNames() { + return Collections.unmodifiableSet(writtenFileNames); + } + + public String getProjectName() { + return projectName; } public Intent getIntent() { @@ -71,10 +113,6 @@ public String getProjectRoot() { return projectRoot; } - public String getGenRoot() { - return genRoot; - } - public IRepository getRepository() { return repository; } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentNaming.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentNaming.java new file mode 100644 index 0000000000..a2c876615e --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentNaming.java @@ -0,0 +1,80 @@ +/* + * 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; + +/** + * Naming conventions shared by every intent generator. The physical table name in particular is + * referenced from three artefacts (the {@code .edm} entity {@code dataName}, the {@code .report} + * {@code baseTable} and the {@code .csvim} {@code table}) - all three must call + * {@link #tableName(IntentGenerationContext, String)} so they can never drift apart. + */ +public final class IntentNaming { + + private IntentNaming() {} + + /** + * The intent's base name used for single-file outputs ({@code .edm}, {@code .roles}) + * and as the physical table-name prefix. The YAML document's own {@code name:} field wins - the + * file is conventionally called {@code app.intent}, so the artefact name derived from the file name + * ({@code app}) is a poor identity. Falls back to the artefact name, then the project name, then + * the literal {@code intent}. + * + * @param context the generation context + * @return the base name, never blank + */ + public static String baseName(IntentGenerationContext context) { + String declaredName = context.getModel() + .getName(); + if (declaredName != null && !declaredName.isBlank()) { + return declaredName; + } + String intentName = context.getIntent() + .getName(); + if (intentName != null && !intentName.isBlank()) { + return intentName; + } + String project = context.getProjectName(); + return project.isEmpty() ? "intent" : project; + } + + /** + * Physical table name for an entity: {@code _} in upper snake (codbex-style, e.g. + * {@code ORDERS_COUNTRY}). The intent-name prefix keeps tables unique across projects sharing a + * schema and away from SQL reserved words like {@code ORDER}. + * + * @param context the generation context + * @param entityName the entity's declared name + * @return the upper-snake, intent-prefixed table name + */ + public static String tableName(IntentGenerationContext context, String entityName) { + return upperSnake(baseName(context)) + "_" + upperSnake(entityName); + } + + /** + * Camel-/Pascal-case to upper snake. Handles {@code IDValue} -> {@code ID_VALUE}. + * + * @param name the identifier to convert (may be null) + * @return the upper-snake form, empty for null/empty input + */ + public static String upperSnake(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(name.length() + 8); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + out.append('_'); + } + out.append(Character.toUpperCase(c)); + } + return out.toString(); + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java index 24b8eacf15..37f161a532 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentRegenerationService.java @@ -10,11 +10,14 @@ package org.eclipse.dirigible.components.intent.generator; import java.util.List; +import java.util.Set; import org.eclipse.dirigible.components.intent.domain.Intent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.repository.api.ICollection; import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -23,12 +26,28 @@ * Orchestrates the regeneration pass for a single {@link Intent}. Hands every registered * {@link IntentTargetGenerator} the same {@link IntentGenerationContext} in {@code @Order} order * and isolates per-generator failures so one broken slice does not block the others. + * + *

    + * After the generators run, model-layer files at the project root that were written by a previous + * pass but not re-emitted by this one are deleted. In an intent project the model files at the + * project root are owned by the regeneration; the extension filter keeps the scrub away from + * {@code app.intent} 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 regeneration instead of leaving a stale artefact + * deployed. */ @Component public class IntentRegenerationService { private static final Logger LOGGER = LoggerFactory.getLogger(IntentRegenerationService.class); + /** + * The model-layer extensions intent generators may emit. Files with one of these extensions at the + * project root are owned (and scrubbed) by the regeneration pass. + */ + private static final Set INTENT_OWNED_EXTENSIONS = Set.of(".edm", ".model", ".bpmn", ".form", ".report", ".roles", ".access", + ".dsm", ".schema", ".table", ".view", ".csvim", ".csv"); + private final List generators; private final IRepository repository; @@ -38,17 +57,20 @@ public IntentRegenerationService(List generators, IReposi } /** - * Re-emit every gen/ artefact for the given intent. The intent's {@code location} is used to derive - * the project root ({@code /registry/public//...}). + * Re-emit every model artefact for the given intent and scrub stale intent-owned files. The + * intent's registry-relative {@code location} (e.g. {@code /orders/app.intent}) is resolved against + * {@code /registry/public} to find the project root. * - * @param intent the intent whose gen/ output should be refreshed + * @param intent the intent whose model output should be refreshed */ public void regenerate(Intent intent) { IntentModel model = IntentParser.parse(intent.getContent()); intent.setModel(model); String projectRoot = resolveProjectRoot(intent.getLocation()); - IntentGenerationContext context = new IntentGenerationContext(intent, model, projectRoot, repository); - LOGGER.info("Regenerating gen/ for intent [{}] under [{}] via {} generator(s)", intent.getName(), projectRoot, generators.size()); + String projectName = resolveProjectName(intent.getLocation()); + IntentGenerationContext context = new IntentGenerationContext(intent, model, projectRoot, projectName, repository); + LOGGER.info("Regenerating model files for intent [{}] under [{}] via {} generator(s)", intent.getName(), projectRoot, + generators.size()); for (IntentTargetGenerator generator : generators) { try { generator.generate(context); @@ -56,16 +78,70 @@ public void regenerate(Intent intent) { LOGGER.error("Intent generator [{}] failed for intent [{}]", generator.name(), intent.getName(), e); } } + scrubStaleModelFiles(context.getProjectRoot(), context.getWrittenFileNames()); } /** - * Strip the file segment from an intent location, returning the project root path. + * Delete every intent-owned model file at the given intent's project root. Called when the + * {@code .intent} file itself is removed, so the model files do not survive their source of truth. + * + * @param intent the deleted intent + */ + public void cleanup(Intent intent) { + scrubStaleModelFiles(resolveProjectRoot(intent.getLocation()), Set.of()); + } + + /** + * Remove intent-owned model files at the project root that are not part of the current output set. + */ + private void scrubStaleModelFiles(String projectRoot, Set keep) { + ICollection project = repository.getCollection(projectRoot); + if (!project.exists()) { + return; + } + for (String fileName : project.getResourcesNames()) { + if (keep.contains(fileName) || !isIntentOwned(fileName)) { + continue; + } + try { + repository.removeResource(projectRoot + "/" + fileName); + LOGGER.info("Scrubbed stale intent output [{}/{}]", projectRoot, fileName); + } catch (RuntimeException e) { + LOGGER.error("Failed to scrub stale intent output [{}/{}]", projectRoot, fileName, e); + } + } + } + + private static boolean isIntentOwned(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot >= 0 && INTENT_OWNED_EXTENSIONS.contains(fileName.substring(dot)); + } + + /** + * Full repository path of the project root: the registry prefix plus the location's directory. + * Artefact locations are registry-relative ({@code //app.intent}), while + * {@code IRepository} paths are repository-absolute, so the {@code /registry/public} prefix is + * mandatory - without it the output lands outside the registry and no downstream synchronizer ever + * sees it. */ private static String resolveProjectRoot(String location) { if (location == null) { - return ""; + return IRepositoryStructure.PATH_REGISTRY_PUBLIC; } int lastSlash = location.lastIndexOf('/'); - return lastSlash <= 0 ? location : location.substring(0, lastSlash); + String relativeRoot = lastSlash <= 0 ? "" : location.substring(0, lastSlash); + return IRepositoryStructure.PATH_REGISTRY_PUBLIC + relativeRoot; + } + + /** + * First non-empty path segment of the registry-relative location. + */ + private static String resolveProjectName(String location) { + if (location == null) { + return ""; + } + int start = location.startsWith("/") ? 1 : 0; + int end = location.indexOf('/', start); + return end < 0 ? "" : location.substring(start, end); } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java index 879e5173b5..dcf18350cd 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java @@ -30,7 +30,7 @@ public interface IntentTargetGenerator { /** * Regenerate this generator's slice of the {@code gen/} output for the given intent. Writes - * exclusively under {@link IntentGenerationContext#getGenRoot()}. + * exclusively through {@link IntentGenerationContext#writeModelFile(String, String)}. * * @param context the per-regeneration call context */ 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 0fcfe97a63..fd2e8ef964 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 @@ -9,7 +9,6 @@ */ package org.eclipse.dirigible.components.intent.generator.bpmn; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -22,16 +21,14 @@ import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.ProcessIntent; import org.eclipse.dirigible.components.intent.model.StepIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; /** - * Emits one {@code gen/.bpmn} per {@link ProcessIntent} declared in the intent. The output - * is a minimal Flowable-flavoured BPMN 2.0 document: + * Emits one {@code .bpmn} (at the project root) per {@link ProcessIntent} declared in the + * intent. The output is a minimal Flowable-flavoured BPMN 2.0 document: *

      *
    • one {@code } and one {@code } per process
    • *
    • {@code userTask} -> @@ -39,8 +36,10 @@ *
    • {@code serviceTask} / {@code script} -> * {@code } with the {@code handler} extension * element pointing at {@code args.call}
    • - *
    • {@code decision} -> {@code } plus a conditioned outgoing flow to - * {@code args.then} and a default flow to the next step
    • + *
    • {@code decision} -> {@code } with a conditioned outgoing flow to + * {@code args.then} and a default flow to {@code args.else} (falling back to the next step in the + * chain when {@code else} is omitted) - so "big orders need CFO review, small ones skip it" is + * expressible
    • *
    • {@code end} -> the canonical end event (no separate element; the outgoing flow targets the * single {@code })
    • *
    @@ -58,6 +57,11 @@ * a template output. * *

    + * The process {@code trigger} block is parsed but not yet consumed - wiring {@code onCreate} / + * {@code onSchedule} to process starts belongs to a follow-up; a warning is logged so authors are + * not silently surprised. + * + *

    * Idempotent: identical input always produces byte-identical output. */ @Component @@ -81,8 +85,6 @@ public void generate(IntentGenerationContext context) { .isEmpty()) { return; } - IRepository repository = context.getRepository(); - String genRoot = context.getGenRoot(); Set seenFiles = new HashSet<>(); for (ProcessIntent process : model.getProcesses()) { if (process.getName() == null || process.getName() @@ -97,8 +99,12 @@ public void generate(IntentGenerationContext context) { .getName()); continue; } - String content = render(process); - writeResource(repository, genRoot + "/" + fileName, content); + if (!process.getTrigger() + .isEmpty()) { + LOGGER.warn("Process [{}] declares a trigger, which is not consumed yet - the process must be started explicitly", + process.getName()); + } + context.writeModelFile(fileName, render(process)); } } @@ -246,23 +252,28 @@ private static void appendExclusiveGateway(StringBuilder sb, StepIntent step) { .append("\" name=\"") .append(escapeXmlAttribute(step.getName())) .append("\" default=\"") - .append("flow_") - .append(step.getName()) - .append("_default\">\n"); + .append(escapeXmlAttribute("flow_" + step.getName() + "_default")) + .append("\">\n"); } /** * Emit the sequence flows. The default is a linear chain through {@link #buildEffectiveStepIds}. - * Decision steps emit an extra conditioned flow to {@code args.then}; the default chained flow to - * the next step in order remains and is marked as the gateway default by ID convention. + * Decision steps emit a conditioned flow to {@code args.then} and route their gateway-default flow + * to {@code args.else} when declared (so the conditioned branch can actually be skipped); without + * an {@code else} the default falls through to the next step in the chain. */ private static void appendSequenceFlows(StringBuilder sb, List steps, List effectiveIds) { for (int i = 0; i < effectiveIds.size() - 1; i++) { String source = effectiveIds.get(i); String target = effectiveIds.get(i + 1); String flowId; - if (isDecisionId(source, steps)) { + StepIntent decision = decisionOf(source, steps); + if (decision != null) { flowId = "flow_" + source + "_default"; + String elseTarget = stringArg(decision, "else"); + if (elseTarget != null && !elseTarget.isBlank()) { + target = effectiveTarget(elseTarget, steps); + } } else { flowId = "flow_" + source + "_" + target; } @@ -289,7 +300,7 @@ private static void appendSequenceFlows(StringBuilder sb, List steps .append("_then\" sourceRef=\"") .append(escapeXmlAttribute(step.getName())) .append("\" targetRef=\"") - .append(escapeXmlAttribute(thenTarget)) + .append(escapeXmlAttribute(effectiveTarget(thenTarget, steps))) .append("\">\n"); sb.append(" steps } } - private static boolean isDecisionId(String stepId, List steps) { + /** + * Resolve a {@code then}/{@code else} target to its BPMN element id: the literal {@code end} or a + * step of kind {@code end} maps to the canonical end event. + */ + private static String effectiveTarget(String targetName, List steps) { + if (END_ID.equalsIgnoreCase(targetName)) { + return END_ID; + } + for (StepIntent step : steps) { + if (targetName.equals(step.getName()) && "end".equalsIgnoreCase(step.getKind())) { + return END_ID; + } + } + return targetName; + } + + /** The decision step with the given id, or null when the id is not a decision. */ + private static StepIntent decisionOf(String stepId, List steps) { for (StepIntent step : steps) { if (stepId.equals(step.getName()) && "decision".equalsIgnoreCase(step.getKind())) { - return true; + return step; } } - return false; + return null; } private static String stringArg(StepIntent step, String key) { @@ -346,14 +374,4 @@ private static String escapeXmlAttribute(String raw) { } return sb.toString(); } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java index 4a298f71e9..9bf80c1a2f 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java @@ -9,25 +9,22 @@ */ package org.eclipse.dirigible.components.intent.generator.csvim; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; 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.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.SeedIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; @@ -36,14 +33,22 @@ /** * Emits two files per {@link SeedIntent}: *

      - *
    • {@code gen/.csvim} - a CSVIM declaration the platform's {@code CsvimSynchronizer} - * consumes. References the sibling CSV file by registry-relative path.
    • - *
    • {@code gen/.csv} - the CSV body. Header row carries the entity's {@code dataName} - * columns (upper-snake of the field names), row order matches the entity's declared field order so - * row authors can omit fields and the column still maps unambiguously.
    • + *
    • {@code .csvim} - a CSVIM declaration the platform's {@code CsvimSynchronizer} consumes. + * The {@code file} path is project-qualified ({@code //.csv}) because + * {@code CsvimProcessor} resolves it against {@code /registry/public}, exactly like the existing + * {@code CsvimIT} fixtures do.
    • + *
    • {@code .csv} - the CSV body. Header row carries the entity's column names (upper-snake + * of the field names prefixed with the entity name); row order matches the entity's declared field + * order so row authors can omit fields and the column still maps unambiguously.
    • *
    * *

    + * The {@code table} value comes from {@link IntentNaming#tableName} - the same intent-prefixed name + * the {@code .edm} declares as {@code dataName}, so the import targets the table the downstream + * template will create. Note the table only exists after "Generate from EDM" output is published; + * until then the CSVIM import is retried by its own synchronizer. + * + *

    * Defaults match the existing {@code CsvimIT} sample: {@code header: true}, * {@code useHeaderNames: true}, {@code delimField: ","}, {@code delimEnclosing: "\""}, * {@code distinguishEmptyFromNull: true}, {@code version: "1.0"}. Schema defaults to {@code PUBLIC} @@ -75,8 +80,6 @@ public void generate(IntentGenerationContext context) { return; } Map entitiesByName = indexEntities(model); - IRepository repository = context.getRepository(); - String genRoot = context.getGenRoot(); Set seenFiles = new HashSet<>(); for (SeedIntent seed : model.getSeeds()) { if (seed.getName() == null || seed.getName() @@ -90,17 +93,14 @@ public void generate(IntentGenerationContext context) { LOGGER.warn("Seed [{}] references unknown entity [{}] - skipping", seed.getName(), seed.getEntity()); continue; } - String fileName = seed.getName(); + String fileName = fileNameOnly(seed.getName()); if (!seenFiles.add(fileName)) { LOGGER.warn("Duplicate seed [{}] in intent [{}] - keeping the first occurrence", seed.getName(), context.getIntent() .getName()); continue; } - List orderedFields = orderedFieldsOf(entity); - String csv = renderCsv(orderedFields, entity, seed); - String csvim = renderCsvim(seed, entity, fileName); - writeResource(repository, genRoot + "/" + fileName + ".csv", csv); - writeResource(repository, genRoot + "/" + fileName + ".csvim", csvim); + context.writeModelFile(fileName + ".csv", renderCsv(orderedFieldsOf(entity), entity, seed)); + context.writeModelFile(fileName + ".csvim", renderCsvim(context, seed, entity, fileName)); } } @@ -132,15 +132,15 @@ private static List orderedFieldsOf(EntityIntent entity) { private static String renderCsv(List fields, EntityIntent entity, SeedIntent seed) { StringBuilder sb = new StringBuilder(256); - String entityDataName = toUpperSnake(entity.getName()); + String entityDataName = IntentNaming.upperSnake(entity.getName()); for (int i = 0; i < fields.size(); i++) { if (i > 0) { sb.append(FIELD_DELIM); } sb.append(entityDataName) .append('_') - .append(toUpperSnake(fields.get(i) - .getName())); + .append(IntentNaming.upperSnake(fields.get(i) + .getName())); } sb.append('\n'); for (Map row : seed.getRows()) { @@ -174,12 +174,12 @@ private static String formatCell(Object value) { return QUOTE_DELIM + s.replace(QUOTE_DELIM, QUOTE_DELIM + QUOTE_DELIM) + QUOTE_DELIM; } - private static String renderCsvim(SeedIntent seed, EntityIntent entity, String fileName) { + private static String renderCsvim(IntentGenerationContext context, SeedIntent seed, EntityIntent entity, String fileName) { Map entry = new LinkedHashMap<>(); - entry.put("table", toUpperSnake(entity.getName())); + entry.put("table", IntentNaming.tableName(context, entity.getName())); entry.put("schema", seed.getSchema() == null || seed.getSchema() .isBlank() ? DEFAULT_SCHEMA : seed.getSchema()); - entry.put("file", "/" + fileNameOnly(fileName) + ".csv"); + entry.put("file", "/" + context.getProjectName() + "/" + fileName + ".csv"); entry.put("header", true); entry.put("useHeaderNames", true); entry.put("delimField", FIELD_DELIM); @@ -198,30 +198,4 @@ private static String fileNameOnly(String name) { int slash = name.lastIndexOf('/'); return slash < 0 ? name : name.substring(slash + 1); } - - private static String toUpperSnake(String name) { - if (name == null || name.isEmpty()) { - return ""; - } - StringBuilder out = new StringBuilder(name.length() + 8); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { - out.append('_'); - } - out.append(Character.toUpperCase(c)); - } - return out.toString() - .toUpperCase(Locale.ROOT); - } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index d0c7a5daa3..017f66f691 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -9,11 +9,10 @@ */ package org.eclipse.dirigible.components.intent.generator.edm; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -21,40 +20,48 @@ 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.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.RelationIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; /** - * Emits {@code gen/.edm} (XML) and its JSON twin {@code gen/.model} for every - * intent that declares one or more entities. The pair is the canonical entity-data-model file - * consumed by the EDM editor in the IDE and by the downstream "Generate from EDM" template engine, - * which turns the model into UI / Java / SQL artefacts in a second step. The intent layer never - * emits those second-stage artefacts itself - that contract belongs to the existing template - * generators. + * Emits {@code .edm} (XML) and its JSON twin {@code .model} for every intent that + * declares one or more entities. The pair is the canonical entity-data-model file consumed by the + * EDM editor in the IDE and by the downstream "Generate from EDM" template engine, which turns the + * model into UI / Java / SQL artefacts in a second step. The intent layer never emits those + * second-stage artefacts itself - that contract belongs to the existing template generators. * *

    - * The intent JSON is intentionally narrower than the EDM XML attribute surface. Everything the EDM - * editor expects but the intent omits (icons, menu keys, layout type, perspective metadata, widget - * type) is filled with conservative defaults derived from the entity / field name: + * Conventions mirrored from real EDM documents (see + * {@code tests-integrations/.../DependsOnScenariosTestProject/sales-order.edm}): *

      - *
    • {@code dataName} = upper-snake of the name
    • - *
    • {@code icon} / {@code perspectiveIcon} = - * {@code /services/web/resources/unicons/file.svg}
    • - *
    • {@code type} = {@code PRIMARY}, or {@code DEPENDENT} if another entity owns it via a - * {@code manyToOne}
    • - *
    • {@code layoutType} = {@code MANAGE_MASTER} / {@code MANAGE_DETAILS} matching the above
    • - *
    • {@code widgetType} = derived from field type (TEXTBOX / NUMBER / DATE / CHECKBOX); FK - * properties get DROPDOWN
    • + *
    • {@code dataName} is prefixed with the intent name: {@code _} (e.g. + * {@code ORDERS_COUNTRY}, codbex-style). This keeps physical table names unique across projects and + * away from SQL reserved words like {@code ORDER}; the {@code .report} and {@code .csvim} + * generators use the same convention so all three artefacts agree on the table name.
    • + *
    • A {@code required} {@code manyToOne}/{@code oneToOne} relation is a composition: the + * FK property carries the {@code relationship*} attributes, the owning entity becomes + * {@code DEPENDENT} with {@code MANAGE_DETAILS} layout and inherits the perspective of its + * (transitively resolved) composition parent. An optional relation is an association: a + * plain DROPDOWN property, the entity stays {@code PRIMARY} with its own perspective.
    • + *
    • Dropdown key / value and the relation's {@code referencedProperty} are derived from the + * target entity's actual fields (its primary key and its {@code name}-like field), never + * hardcoded.
    • + *
    • The {@code .model} JSON carries {@code entities} / {@code perspectives} / + * {@code navigations}; relations appear only in the XML as {@code } elements interleaved + * with their owning {@code }.
    • *
    + * No {@code mxGraphModel} diagram block is emitted - the EDM editor lays out a missing diagram on + * first open, which keeps the output deterministic across regenerations. + * + *

    * Idempotent: identical input always produces byte-identical output. */ @Component @@ -77,26 +84,30 @@ public void generate(IntentGenerationContext context) { .isEmpty()) { return; } - Map root = buildModel(model); - String baseName = baseName(context); - String genRoot = context.getGenRoot(); - IRepository repo = context.getRepository(); - writeResource(repo, genRoot + "/" + baseName + ".model", JsonHelper.toJson(root)); - writeResource(repo, genRoot + "/" + baseName + ".edm", renderEdmXml(root)); + String baseName = IntentNaming.baseName(context); + EdmDocument document = buildDocument(model, baseName); + context.writeModelFile(baseName + ".model", JsonHelper.toJson(document.modelJson)); + context.writeModelFile(baseName + ".edm", renderEdmXml(document)); } - /** - * Build the typed map that mirrors the canonical {@code .model} JSON shape. Both the JSON - * serializer and the XML renderer consume this same tree, so the two on-disk formats can never - * drift. - */ - private static Map buildModel(IntentModel model) { + /** The two views over one model tree: the {@code .model} JSON root and the XML extras. */ + private static final class EdmDocument { + /** Root of the {@code .model} JSON: {@code {model: {entities, perspectives, navigations}}}. */ + final Map modelJson = new LinkedHashMap<>(); + /** Top-level {@code } elements per owning entity name, XML-only. */ + final Map>> relationsByEntity = new LinkedHashMap<>(); + } + + private static EdmDocument buildDocument(IntentModel model, String intentName) { List entities = model.getEntities(); - Set dependentEntities = computeDependents(entities); + Map byName = indexEntities(entities); + Map compositionParents = computeCompositionParents(entities); + EdmDocument document = new EdmDocument(); List> entityList = new ArrayList<>(); - List> relationList = new ArrayList<>(); - int order = 1; + List> perspectiveList = new ArrayList<>(); + String tablePrefix = IntentNaming.upperSnake(intentName); + int perspectiveOrder = 1; for (EntityIntent entity : entities) { String name = entity.getName(); @@ -104,8 +115,14 @@ private static Map buildModel(IntentModel model) { LOGGER.warn("Skipping unnamed entity in intent"); continue; } - boolean dependent = dependentEntities.contains(name); - Map entityMap = entityDefaults(name, entity.getDescription(), dependent, order++); + boolean dependent = compositionParents.containsKey(name); + String perspective = resolvePerspective(name, compositionParents); + Map entityMap = + entityDefaults(name, entity.getDescription(), dependent, perspective, tablePrefix, perspectiveOrder); + if (!dependent) { + perspectiveList.add(perspectiveEntry(name, perspectiveOrder)); + perspectiveOrder++; + } List> properties = new ArrayList<>(); for (FieldIntent field : entity.getFields()) { @@ -115,6 +132,8 @@ private static Map buildModel(IntentModel model) { } properties.add(propertyMap(name, field)); } + List> relations = new ArrayList<>(); + boolean compositionAssigned = false; for (RelationIntent relation : entity.getRelations()) { if (!"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { continue; @@ -122,41 +141,89 @@ private static Map buildModel(IntentModel model) { if (relation.getName() == null || relation.getTo() == null) { continue; } - properties.add(relationProperty(name, relation)); - relationList.add(relationLink(name, relation)); + boolean composition = !compositionAssigned && relation.isRequired(); + compositionAssigned |= composition; + EntityIntent target = byName.get(relation.getTo()); + properties.add(relationProperty(name, relation, target, composition)); + relations.add(relationLink(name, relation, target, compositionParents)); } entityMap.put("properties", properties); entityList.add(entityMap); + if (!relations.isEmpty()) { + document.relationsByEntity.put(name, relations); + } } Map body = new LinkedHashMap<>(); body.put("entities", entityList); - if (!relationList.isEmpty()) { - body.put("relations", relationList); + body.put("perspectives", perspectiveList); + body.put("navigations", new ArrayList<>()); + document.modelJson.put("model", body); + return document; + } + + private static Map indexEntities(List entities) { + Map index = new HashMap<>(); + for (EntityIntent entity : entities) { + if (entity.getName() != null) { + index.put(entity.getName(), entity); + } } - Map root = new LinkedHashMap<>(); - root.put("model", body); - return root; + return index; } /** - * Build the set of entity names that are owned by another entity through an outgoing - * {@code manyToOne}. Used to decide PRIMARY vs DEPENDENT and MASTER vs DETAILS layouts. + * Map each entity to its composition parent: the target of its first {@code required} + * {@code manyToOne} / {@code oneToOne} relation. Entities present as keys are DEPENDENT; their + * perspective is the parent's, resolved transitively by {@link #resolvePerspective}. */ - private static Set computeDependents(List entities) { - Map result = new HashMap<>(); + private static Map computeCompositionParents(List entities) { + Map parents = new LinkedHashMap<>(); for (EntityIntent entity : entities) { + if (entity.getName() == null) { + continue; + } for (RelationIntent relation : entity.getRelations()) { - if ("manyToOne".equals(relation.getKind()) && entity.getName() != null) { - result.put(entity.getName(), true); + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && relation.isRequired() && relation.getTo() != null) { + parents.put(entity.getName(), relation.getTo()); + break; } } } - return result.keySet(); + return parents; + } + + /** + * Follow the composition-parent chain to the first PRIMARY entity; that entity's name is the + * perspective every entity in the chain lives under. A cycle (mutually required relations) falls + * back to the starting entity itself. + */ + private static String resolvePerspective(String entityName, Map compositionParents) { + String current = entityName; + Set visited = new LinkedHashSet<>(); + while (compositionParents.containsKey(current)) { + if (!visited.add(current)) { + LOGGER.warn("Composition cycle detected at entity [{}] - keeping its own perspective", entityName); + return entityName; + } + current = compositionParents.get(current); + } + return current; + } + + private static Map perspectiveEntry(String name, int order) { + Map perspective = new LinkedHashMap<>(); + perspective.put("name", name); + perspective.put("label", name); + perspective.put("icon", DEFAULT_ICON); + perspective.put("order", Integer.toString(order)); + return perspective; } - private static Map entityDefaults(String name, String description, boolean dependent, int order) { - String dataName = toUpperSnake(name); + private static Map entityDefaults(String name, String description, boolean dependent, String perspective, + String tablePrefix, int order) { + String dataName = tablePrefix + "_" + IntentNaming.upperSnake(name); Map entity = new LinkedHashMap<>(); entity.put("name", name); entity.put("dataName", dataName); @@ -172,8 +239,8 @@ private static Map entityDefaults(String name, String descriptio entity.put("menuLabel", name); entity.put("menuIndex", "100"); entity.put("layoutType", dependent ? "MANAGE_DETAILS" : "MANAGE_MASTER"); - entity.put("perspectiveName", name); - entity.put("perspectiveLabel", name); + entity.put("perspectiveName", perspective); + entity.put("perspectiveLabel", perspective); entity.put("perspectiveHeader", ""); entity.put("perspectiveIcon", DEFAULT_ICON); entity.put("perspectiveOrder", Integer.toString(order)); @@ -185,7 +252,7 @@ private static Map entityDefaults(String name, String descriptio } private static Map propertyMap(String entityName, FieldIntent field) { - String column = toUpperSnake(entityName) + "_" + toUpperSnake(field.getName()); + String column = IntentNaming.upperSnake(entityName) + "_" + IntentNaming.upperSnake(field.getName()); String dataType = mapDataType(field.getType()); Map p = new LinkedHashMap<>(); p.put("name", field.getName()); @@ -200,7 +267,7 @@ private static Map propertyMap(String entityName, FieldIntent fi if (field.isPrimaryKey() && field.isGenerated()) { p.put("dataAutoIncrement", "true"); } - Integer length = field.getLength() != null ? field.getLength() : defaultLength(dataType); + Integer length = fieldLength(field); if (length != null && length > 0) { p.put("dataLength", length.toString()); } @@ -221,47 +288,117 @@ private static Map propertyMap(String entityName, FieldIntent fi /** * FK property added to the owning entity for a {@code manyToOne}/{@code oneToOne} relation. Renders - * as a DROPDOWN bound to the target entity's Id/Name. + * as a DROPDOWN keyed by the target entity's actual primary-key field and labelled by its + * {@code name}-like field. Only the entity's composition relation (its first {@code required} + * to-one) carries the {@code relationship*} attributes that make the EDM editor treat the owner as + * a detail of the target - further required relations stay plain NOT NULL associations, mirroring + * how the EDM editor writes multi-FK entities. */ - private static Map relationProperty(String ownerEntity, RelationIntent relation) { - String column = toUpperSnake(ownerEntity) + "_" + toUpperSnake(relation.getName()); + private static Map relationProperty(String ownerEntity, RelationIntent relation, EntityIntent target, + boolean composition) { + String column = IntentNaming.upperSnake(ownerEntity) + "_" + IntentNaming.upperSnake(relation.getName()); + FieldIntent targetPk = primaryKeyOf(target); + String fkType = targetPk == null ? "INTEGER" : mapDataType(targetPk.getType()); Map p = new LinkedHashMap<>(); p.put("name", relation.getName()); p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); p.put("tooltip", ""); p.put("dataName", column); - p.put("dataType", "INTEGER"); + p.put("dataType", fkType); p.put("dataNullable", relation.isRequired() ? "false" : "true"); - p.put("relationshipType", "COMPOSITION"); - p.put("relationshipCardinality", "1_n"); - p.put("relationshipName", relation.getName()); + if ("VARCHAR".equals(fkType) && targetPk != null) { + Integer length = fieldLength(targetPk); + if (length != null && length > 0) { + p.put("dataLength", length.toString()); + } + } + if (composition) { + p.put("relationshipType", "COMPOSITION"); + p.put("relationshipCardinality", "1_n"); + p.put("relationshipName", relation.getName()); + } p.put("widgetType", "DROPDOWN"); p.put("widgetSize", ""); p.put("widgetLength", "20"); p.put("widgetIsMajor", "true"); - p.put("widgetDropDownKey", "Id"); - p.put("widgetDropDownValue", "Name"); + p.put("widgetDropDownKey", keyFieldName(target)); + p.put("widgetDropDownValue", labelFieldName(target)); return p; } /** - * Top-level {@code } link that the EDM editor uses to render arrows on the canvas. + * Top-level {@code } element interleaved with its owning {@code } in the XML. + * {@code relationshipEntityPerspectiveName} is the target's resolved perspective - for a + * dependent target that is its composition parent's perspective, mirroring how the EDM editor + * writes these links. */ - private static Map relationLink(String ownerEntity, RelationIntent relation) { + private static Map relationLink(String ownerEntity, RelationIntent relation, EntityIntent target, + Map compositionParents) { Map link = new LinkedHashMap<>(); String linkName = ownerEntity + "_" + relation.getName(); link.put("name", linkName); link.put("type", "relation"); link.put("entity", ownerEntity); link.put("relationName", linkName); - link.put("relationshipEntityPerspectiveName", relation.getTo()); + link.put("relationshipEntityPerspectiveName", resolvePerspective(relation.getTo(), compositionParents)); link.put("relationshipEntityPerspectiveLabel", "Entities"); link.put("property", relation.getName()); link.put("referenced", relation.getTo()); - link.put("referencedProperty", "Id"); + link.put("referencedProperty", keyFieldName(target)); return link; } + /** The target entity's primary-key field, or null when the target is unknown or has no PK. */ + private static FieldIntent primaryKeyOf(EntityIntent entity) { + if (entity == null) { + return null; + } + for (FieldIntent field : entity.getFields()) { + if (field.isPrimaryKey() && field.getName() != null) { + return field; + } + } + return null; + } + + /** The dropdown key: the target's actual PK field name; {@code Id} only as a last resort. */ + private static String keyFieldName(EntityIntent target) { + FieldIntent pk = primaryKeyOf(target); + return pk == null ? "Id" : pk.getName(); + } + + /** + * The dropdown label: the target's {@code name} field (case-insensitive), else its first + * string-typed field, else its PK. + */ + private static String labelFieldName(EntityIntent target) { + if (target == null) { + return "Name"; + } + for (FieldIntent field : target.getFields()) { + if (field.getName() != null && "name".equalsIgnoreCase(field.getName())) { + return field.getName(); + } + } + for (FieldIntent field : target.getFields()) { + if (field.getName() != null && "VARCHAR".equals(mapDataType(field.getType())) && !field.isPrimaryKey()) { + return field.getName(); + } + } + return keyFieldName(target); + } + + /** Declared length, with type-derived defaults ({@code uuid} -> 36). */ + private static Integer fieldLength(FieldIntent field) { + if (field.getLength() != null) { + return field.getLength(); + } + if (field.getType() != null && "uuid".equalsIgnoreCase(field.getType())) { + return 36; + } + return defaultLength(mapDataType(field.getType())); + } + private static String mapDataType(String type) { if (type == null) { return "VARCHAR"; @@ -325,42 +462,22 @@ private static Integer defaultLength(String dataType) { } /** - * Camel-/Pascal-case to upper snake. Handles {@code IDValue} -> {@code ID_VALUE}. - */ - private static String toUpperSnake(String name) { - if (name == null || name.isEmpty()) { - return ""; - } - StringBuilder out = new StringBuilder(name.length() + 8); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { - out.append('_'); - } - out.append(Character.toUpperCase(c)); - } - return out.toString(); - } - - /** - * Render the typed model tree as the EDM XML shape. The XML is deliberately minimal - the EDM - * editor accepts files without the {@code } or {@code }-wrapped extension blocks and - * fills its own defaults on first edit. + * Render the EDM XML shape: entities with their relations interleaved, then the perspectives and + * navigations blocks, mirroring documents the EDM editor itself writes (minus the + * {@code mxGraphModel} diagram, which the editor recreates). */ @SuppressWarnings("unchecked") - private static String renderEdmXml(Map root) { - Map body = (Map) root.get("model"); - List> entities = - body == null ? Collections.emptyList() : (List>) body.getOrDefault("entities", Collections.emptyList()); - List> relations = body == null ? Collections.emptyList() - : (List>) body.getOrDefault("relations", Collections.emptyList()); + private static String renderEdmXml(EdmDocument document) { + Map body = (Map) document.modelJson.get("model"); + List> entities = (List>) body.get("entities"); + List> perspectives = (List>) body.get("perspectives"); StringBuilder sb = new StringBuilder(4096); sb.append("\n"); - sb.append(" \n"); + sb.append(" \n"); for (Map entity : entities) { - sb.append(" > properties = (List>) entity.getOrDefault("properties", Collections.emptyList()); + sb.append(" > properties = (List>) entity.getOrDefault("properties", List.of()); for (Map.Entry attr : entity.entrySet()) { if ("properties".equals(attr.getKey())) { continue; @@ -369,22 +486,40 @@ private static String renderEdmXml(Map root) { } sb.append(">\n"); for (Map property : properties) { - sb.append(" attr : property.entrySet()) { appendAttribute(sb, attr.getKey(), attr.getValue()); } sb.append(">\n"); } - sb.append(" \n"); - } - sb.append(" \n"); - for (Map relation : relations) { - sb.append(" attr : relation.entrySet()) { - appendAttribute(sb, attr.getKey(), attr.getValue()); + sb.append(" \n"); + List> relations = document.relationsByEntity.get(entity.get("name")); + if (relations != null) { + for (Map relation : relations) { + sb.append(" attr : relation.entrySet()) { + appendAttribute(sb, attr.getKey(), attr.getValue()); + } + sb.append(">\n \n"); + } } - sb.append(">\n"); } + sb.append(" \n"); + sb.append(" \n"); + for (Map perspective : perspectives) { + sb.append(" ") + .append(escapeXmlText(perspective.get("name"))) + .append("") + .append(escapeXmlText(perspective.get("icon"))) + .append("") + .append(escapeXmlText(perspective.get("order"))) + .append("\n"); + } + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); sb.append("\n"); return sb.toString(); } @@ -393,11 +528,15 @@ private static void appendAttribute(StringBuilder sb, String key, Object value) sb.append(' ') .append(key) .append("=\"") - .append(escapeXmlAttribute(value == null ? "" : value.toString())) + .append(escapeXml(value == null ? "" : value.toString())) .append("\""); } - private static String escapeXmlAttribute(String raw) { + private static String escapeXmlText(Object value) { + return escapeXml(value == null ? "" : value.toString()); + } + + private static String escapeXml(String raw) { StringBuilder sb = new StringBuilder(raw.length() + 8); for (int i = 0; i < raw.length(); i++) { char c = raw.charAt(i); @@ -425,23 +564,4 @@ private static String escapeXmlAttribute(String raw) { return sb.toString(); } - private static String baseName(IntentGenerationContext context) { - String intentName = context.getIntent() - .getName(); - if (intentName != null && !intentName.isBlank()) { - return intentName; - } - String project = context.getProjectName(); - return project.isEmpty() ? "intent" : project; - } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } } diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java index 95daa9f8d2..4c8e09b95c 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java @@ -9,7 +9,6 @@ */ package org.eclipse.dirigible.components.intent.generator.form; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; @@ -26,16 +25,14 @@ import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.FormIntent; 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; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; /** - * Emits one {@code gen/.form} per {@link FormIntent} declared in the intent. The output is - * the JSON shape consumed by the form-builder editor in the IDE - a {@code form} array of typed + * Emits one {@code .form} per {@link FormIntent} declared in the intent. The output is the + * JSON shape consumed by the form-builder editor in the IDE - a {@code form} array of typed * controls (header, input-textfield / input-number / input-date / input-checkbox, container-hbox * with buttons) plus the customary {@code metadata} / {@code feeds} / {@code scripts} / * {@code code} companions. @@ -85,8 +82,6 @@ public void generate(IntentGenerationContext context) { return; } Map entitiesByName = indexEntities(model); - IRepository repository = context.getRepository(); - String genRoot = context.getGenRoot(); Set seenFiles = new HashSet<>(); for (FormIntent form : model.getForms()) { if (form.getName() == null || form.getName() @@ -103,7 +98,7 @@ public void generate(IntentGenerationContext context) { } EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity()); Map document = buildForm(form, boundEntity); - writeResource(repository, genRoot + "/" + fileName, JsonHelper.toJson(document)); + context.writeModelFile(fileName, JsonHelper.toJson(document)); } } @@ -342,16 +337,6 @@ private static String pascalCase(String raw) { return out.toString(); } - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } - /** * Internal pairing of form-builder control id with HTML input type. The HTML {@code type} attribute * is omitted for control IDs that don't take one (e.g. checkbox). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java index 2ce25a1992..6db9a8213b 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java @@ -9,7 +9,6 @@ */ package org.eclipse.dirigible.components.intent.generator.permission; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -18,18 +17,17 @@ 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.PermissionIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; /** - * Emits {@code gen/.roles} from the intent's {@code permissions}. The roles are deduped by + * Emits {@code .roles} from the intent's {@code permissions}. The roles are deduped by * name; the {@code can: [Resource:action, ...]} tokens are NOT translated into {@code .access} * constraints here. URL-shaped access constraints belong to the downstream EDM / form / report * template generators, which know the paths they will publish; emitting them from the intent layer @@ -67,9 +65,7 @@ public void generate(IntentGenerationContext context) { .isEmpty()) { return; } - String baseName = baseName(context); - String path = context.getGenRoot() + "/" + baseName + ".roles"; - writeResource(context.getRepository(), path, buildRolesJson(model)); + context.writeModelFile(IntentNaming.baseName(context) + ".roles", buildRolesJson(model)); } private static String buildRolesJson(IntentModel model) { @@ -94,24 +90,4 @@ private static String buildRolesJson(IntentModel model) { } return JsonHelper.toJson(roles); } - - private static String baseName(IntentGenerationContext context) { - String intentName = context.getIntent() - .getName(); - if (intentName != null && !intentName.isBlank()) { - return intentName; - } - String project = context.getProjectName(); - return project.isEmpty() ? "intent" : project; - } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } } 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 d921d572ef..09069e04f0 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 @@ -9,7 +9,6 @@ */ package org.eclipse.dirigible.components.intent.generator.report; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; @@ -22,19 +21,18 @@ 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.ReportIntent; -import org.eclipse.dirigible.repository.api.IRepository; -import org.eclipse.dirigible.repository.api.IResource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; /** - * Emits one {@code gen/.report} per {@link ReportIntent} declared in the intent. The output - * is the JSON shape the report editor in the IDE consumes: an outer record with {@code alias} / + * Emits one {@code .report} per {@link ReportIntent} declared in the intent. The output is + * the JSON shape the report editor in the IDE consumes: an outer record with {@code alias} / * {@code tId} / {@code label} / {@code query} plus a {@code columns} array whose entries carry * {@code name} / {@code alias} / {@code type} / {@code aggregate}. * @@ -80,8 +78,6 @@ public void generate(IntentGenerationContext context) { .isEmpty()) { return; } - IRepository repository = context.getRepository(); - String genRoot = context.getGenRoot(); Set seenFiles = new HashSet<>(); for (ReportIntent report : model.getReports()) { if (report.getName() == null || report.getName() @@ -96,12 +92,12 @@ public void generate(IntentGenerationContext context) { .getName()); continue; } - Map document = build(report); - writeResource(repository, genRoot + "/" + fileName, JsonHelper.toJson(document)); + Map document = build(context, report); + context.writeModelFile(fileName, JsonHelper.toJson(document)); } } - private static Map build(ReportIntent report) { + private static Map build(IntentGenerationContext context, ReportIntent report) { Map document = new LinkedHashMap<>(); document.put("alias", report.getName()); document.put("tId", translationId(report.getName())); @@ -112,7 +108,7 @@ private static Map build(ReportIntent report) { } if (report.getSource() != null && !report.getSource() .isBlank()) { - document.put("baseTable", toUpperSnake(report.getSource())); + document.put("baseTable", IntentNaming.tableName(context, report.getSource())); } document.put("query", ""); document.put("columns", buildColumns(report)); @@ -250,29 +246,4 @@ private static String humanize(String raw) { } return out.toString(); } - - private static String toUpperSnake(String name) { - if (name == null || name.isEmpty()) { - return ""; - } - StringBuilder out = new StringBuilder(name.length() + 8); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { - out.append('_'); - } - out.append(Character.toUpperCase(c)); - } - return out.toString(); - } - - private static void writeResource(IRepository repository, String path, String content) { - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - IResource existing = repository.getResource(path); - if (existing.exists()) { - existing.setContent(bytes); - } else { - repository.createResource(path, bytes); - } - } } 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 fe3064d8dd..aaed4e6760 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 @@ -15,7 +15,6 @@ import java.util.Locale; import java.util.Set; -import org.eclipse.dirigible.components.base.helpers.JsonHelper; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.FormIntent; @@ -29,10 +28,14 @@ import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.constructor.SafeConstructor; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.ToNumberPolicy; + /** * Parses the YAML payload of a {@code .intent} file into an {@link IntentModel} tree. SnakeYAML - * loads the document into a generic map; that map is then round-tripped through Gson via - * {@link JsonHelper} so the typed-POJO mapping stays in a single place. + * loads the document into a generic map; that map is then round-tripped through a plain Gson + * instance (see {@link #GSON}) so the typed-POJO mapping stays in a single place. * *

    * SafeConstructor blocks the {@code !!type} / {@code !!new} tags - YAML deserialisation of intents @@ -52,6 +55,17 @@ public final class IntentParser { private static final Set RELATION_KINDS = Set.of("oneToMany", "manyToOne", "oneToOne", "manyToMany"); private static final Set STEP_KINDS = Set.of("userTask", "serviceTask", "decision", "script", "end"); + /** + * Plain Gson for the YAML-Map -> JSON -> POJO round-trip. The platform's {@code JsonHelper} / + * {@code GsonHelper} cannot be used here: they are configured with + * {@code excludeFieldsWithoutExposeAnnotation()}, which silently maps every un-annotated model + * field to null/empty - the parser then "succeeds" with an empty {@link IntentModel} and every + * generator quietly skips its slice. {@code LONG_OR_DOUBLE} keeps YAML integers integral (seed row + * {@code id: 1} must render as {@code 1} in the CSV, not {@code 1.0}). + */ + private static final Gson GSON = new GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create(); + private IntentParser() {} /** @@ -70,8 +84,8 @@ public static IntentModel parse(String yaml) { if (tree == null) { return new IntentModel(); } - String json = JsonHelper.toJson(tree); - IntentModel model = JsonHelper.fromJson(json, IntentModel.class); + String json = GSON.toJson(tree); + IntentModel model = GSON.fromJson(json, IntentModel.class); if (model == null) { return new IntentModel(); } @@ -181,9 +195,56 @@ private static void validateProcesses(IntentModel model, List issues) { "process [" + process.getName() + "] step [" + step.getName() + "] has unknown kind [" + step.getKind() + "]"); } } + validateDecisionTargets(process, issues); } } + /** + * Decision steps must declare {@code if} and {@code then}; {@code then} and the optional + * {@code else} must reference a declared step of the same process (or the literal {@code end}). + * Without this check a typo silently produces BPMN that Flowable rejects on the next + * synchronization cycle. + */ + private static void validateDecisionTargets(ProcessIntent process, List issues) { + Set stepNames = new HashSet<>(); + for (StepIntent step : process.getSteps()) { + if (step.getName() != null) { + stepNames.add(step.getName()); + } + } + for (StepIntent step : process.getSteps()) { + if (!"decision".equals(step.getKind()) || step.getName() == null) { + continue; + } + String condition = stepArg(step, "if"); + String thenTarget = stepArg(step, "then"); + if (condition == null || condition.isBlank() || thenTarget == null || thenTarget.isBlank()) { + issues.add("process [" + process.getName() + "] decision [" + step.getName() + "] must declare both `if` and `then`"); + continue; + } + checkDecisionTarget(process, step, "then", thenTarget, stepNames, issues); + String elseTarget = stepArg(step, "else"); + if (elseTarget != null && !elseTarget.isBlank()) { + checkDecisionTarget(process, step, "else", elseTarget, stepNames, issues); + } + } + } + + private static void checkDecisionTarget(ProcessIntent process, StepIntent step, String arg, String target, Set stepNames, + List issues) { + if (!"end".equalsIgnoreCase(target) && !stepNames.contains(target)) { + issues.add("process [" + process.getName() + "] decision [" + step.getName() + "] `" + arg + "` references unknown step [" + + target + "]"); + } + } + + private static String stepArg(StepIntent step, String key) { + Object value = step.getArgs() == null ? null + : step.getArgs() + .get(key); + return value == null ? null : value.toString(); + } + private static void validateForms(IntentModel model, Set entityNames, List issues) { Set formNames = new HashSet<>(); for (FormIntent form : model.getForms()) { diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java index 2d59bb05ef..3f123bc437 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/synchronizer/IntentSynchronizer.java @@ -35,12 +35,12 @@ /** * Synchronizer for {@code .intent} files. Sits at the top of {@link SynchronizersOrder} so its - * regenerated {@code gen/} output participates in the next reconciliation cycle ahead of the - * downstream entity / schema / BPMN / form synchronizers. + * regenerated model files participates in the next reconciliation cycle ahead of the downstream + * entity / schema / BPMN / form synchronizers. * *

    * The synchronizer itself owns no runtime state - it persists the intent's JSON payload and lets - * {@link IntentRegenerationService} produce / refresh the gen/ files in {@link #finishing()}. + * {@link IntentRegenerationService} produce / refresh the model files in {@link #finishing()}. * Lifecycle transitions are pure book-keeping; nothing to start, nothing to stop. */ @Component @@ -57,7 +57,7 @@ public class IntentSynchronizer extends BaseSynchronizer { private SynchronizerCallback callback; /** - * Intents that changed in the current cycle and need their gen/ output refreshed in + * Intents that changed in the current cycle and need their model output refreshed in * {@link #finishing()}. Keyed by location to coalesce repeated parses of the same file. */ private final Map dirty = new LinkedHashMap<>(); @@ -101,6 +101,7 @@ protected List parseImpl(String location, byte[] content) throws ParseEx synchronized (dirty) { dirty.put(location, intent); } + LOGGER.info("Parsed intent [{}] - marked for regeneration", location); return List.of(intent); } @@ -153,6 +154,7 @@ protected boolean completeImpl(TopologyWrapper wrapper, ArtefactPhase fl @Override public void cleanupImpl(Intent intent) { try { + regenerationService.cleanup(intent); intentService.delete(intent); } catch (RuntimeException e) { LOGGER.error("Failed to delete intent [{}]", intent.getLocation(), e); @@ -196,14 +198,16 @@ public void finishing() { try { regenerationService.regenerate(intent); } catch (RuntimeException e) { - LOGGER.error("Failed to regenerate gen/ for intent [{}]", intent.getLocation(), e); + LOGGER.error("Failed to regenerate model files for intent [{}]", intent.getLocation(), e); } } } /** - * Derive a logical name from the file location: strip the path and the {@code .intent} extension, - * returning the base name. Used when the intent JSON itself omits the name field. + * Derive the artefact name from the file location: strip the path and the {@code .intent} + * extension, returning the base name. This is only the artefact's identity in the database - the + * generators' output naming prefers the YAML document's own {@code name:} field (see + * {@code IntentNaming.baseName}), since the file is conventionally called {@code app.intent}. */ private static String deriveName(String location) { int lastSlash = location.lastIndexOf('/'); diff --git a/components/ui/view-intent-mermaid/pom.xml b/components/ui/view-intent-mermaid/pom.xml index c659007c07..e0cf96dc28 100644 --- a/components/ui/view-intent-mermaid/pom.xml +++ b/components/ui/view-intent-mermaid/pom.xml @@ -13,6 +13,22 @@ dirigible-components-ui-view-intent-mermaid jar + + + + org.webjars.npm + mermaid + ${mermaid.version} + + + * + * + + + + + ../../../licensing-header.txt ../../../ diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html index 62dd99518d..99445c815f 100644 --- a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html +++ b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html @@ -20,7 +20,7 @@ - + + + + + + Intent + + + + + + + + Editor encountered an error + {{errorMessage}} + + +

      +
    • {{issue}}
    • +
    + +
    +
    + +
    +
    +
    +
    +

    Forms

    +
      +
    • + {{f.name}} + for {{f.forEntity}} + — actions: {{f.actions.join(', ')}} +
    • +
    +
    +
    +

    Reports

    +
      +
    • + {{r.name}} + on {{r.source}} + — {{r.measures.join(', ')}} +
    • +
    +
    +
    +

    Roles

    +
      +
    • + {{perm.role}} + — {{perm.can.join(', ')}} +
    • +
    +
    +
    +

    Seed data

    +
      +
    • + {{s.name}} + into {{s.entity}} + — {{s.rows.length}} row(s) +
    • +
    +
    +
    +
    + + + + + + diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/extensions/editor.extension b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/extensions/editor.extension new file mode 100644 index 0000000000..fb81819c3a --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/extensions/editor.extension @@ -0,0 +1,5 @@ +{ + "module": "editor-intent/configs/intent-editor.js", + "extensionPoint": "platform-editors", + "description": "Intent Editor" +} diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js new file mode 100644 index 0000000000..385e91f885 --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js @@ -0,0 +1,322 @@ +/* + * 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 + */ +const editorView = angular.module('intentEditor', ['blimpKit', 'platformView', 'platformShortcuts', 'WorkspaceService']); +editorView.controller('IntentEditorController', ($scope, $http, $sce, ViewParameters, WorkspaceService) => { + const statusBarHub = new StatusBarHub(); + const workspaceHub = new WorkspaceHub(); + const layoutHub = new LayoutHub(); + const dialogHub = new DialogHub(); + const PARSE_URL = '/services/ide/intent/parse'; + const GENERATE_URL = '/services/ide/intent/generate'; + + $scope.state = { isBusy: true, error: false }; + $scope.errorMessage = ''; + $scope.changed = false; + $scope.text = ''; + $scope.model = { entities: [], processes: [], forms: [], reports: [], permissions: [], seeds: [] }; + $scope.issues = []; + $scope.diagramSvg = ''; + let savedText = ''; + let parseTimer = null; + let renderCounter = 0; + + if (window.mermaid && typeof window.mermaid.initialize === 'function') { + window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default' }); + } + + // ----- File location ----------------------------------------------------- + + /** filePath has the shape /// */ + const fileLocation = () => { + const parts = $scope.dataParameters.filePath.split('/'); + return { + workspace: parts[1], + project: parts[2], + path: parts.slice(3) + .join('/'), + }; + }; + + // ----- Load / save --------------------------------------------------------- + + const loadFileContents = () => { + WorkspaceService.loadContent($scope.dataParameters.filePath).then((response) => { + $scope.$evalAsync(() => { + $scope.text = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); + savedText = $scope.text; + $scope.state.isBusy = false; + refreshPreview(); + }); + }, (response) => { + console.error(response); + $scope.$evalAsync(() => { + $scope.state.error = true; + $scope.errorMessage = 'Error while loading the intent file. Please look at the console for more information.'; + $scope.state.isBusy = false; + }); + }); + }; + + $scope.save = () => { + if (!$scope.changed || $scope.state.error) return; + $scope.state.isBusy = true; + WorkspaceService.saveContent($scope.dataParameters.filePath, $scope.text).then(() => { + savedText = $scope.text; + layoutHub.setEditorDirty({ + path: $scope.dataParameters.filePath, + dirty: false, + }); + workspaceHub.announceFileSaved({ + path: $scope.dataParameters.filePath, + contentType: $scope.dataParameters.contentType, + }); + $scope.$evalAsync(() => { + $scope.changed = false; + $scope.state.isBusy = false; + }); + }, (response) => { + console.error(response); + $scope.$evalAsync(() => { + $scope.state.error = true; + $scope.errorMessage = `Error saving "${$scope.dataParameters.filePath}". Please look at the console for more information.`; + $scope.state.isBusy = false; + }); + }); + }; + + // ----- Live preview -------------------------------------------------------- + + $scope.onTextChange = () => { + const dirty = $scope.text !== savedText; + if (dirty !== $scope.changed) { + $scope.changed = dirty; + layoutHub.setEditorDirty({ + path: $scope.dataParameters.filePath, + dirty: dirty, + }); + } + if (parseTimer) clearTimeout(parseTimer); + parseTimer = setTimeout(refreshPreview, 600); + }; + + const refreshPreview = () => { + $http.post(PARSE_URL, $scope.text || '', { headers: { 'Content-Type': 'text/plain' } }).then((response) => { + $scope.issues = []; + $scope.model = normalize(response.data); + render(); + }, (response) => { + $scope.$evalAsync(() => { + if (response.status === 422 && response.data && response.data.issues) { + $scope.issues = response.data.issues; + } else { + $scope.issues = ['Unable to parse the intent. Please look at the console for more information.']; + console.error(response); + } + }); + }); + }; + + const normalize = (model) => { + model = model || {}; + model.entities = model.entities || []; + model.processes = model.processes || []; + model.forms = model.forms || []; + model.reports = model.reports || []; + model.permissions = model.permissions || []; + model.seeds = model.seeds || []; + return model; + }; + + // ----- Generate ------------------------------------------------------------ + + $scope.generate = () => { + const location = fileLocation(); + $scope.state.isBusy = true; + dialogHub.showBusyDialog('Generating model files'); + $http.post(`${GENERATE_URL}?workspace=${encodeURIComponent(location.workspace)}&project=${encodeURIComponent(location.project)}&path=${encodeURIComponent(location.path)}`) + .then((response) => { + dialogHub.closeBusyDialog(); + const written = (response.data.written || []).length; + const scrubbed = (response.data.scrubbed || []).length; + statusBarHub.showMessage(`Generated ${written} model file(s)${scrubbed ? `, removed ${scrubbed} stale` : ''} in '${location.project}'`); + dialogHub.postMessage({ topic: 'projects.tree.refresh', data: { partial: true, project: location.project, workspace: location.workspace } }); + $scope.$evalAsync(() => { + $scope.state.isBusy = false; + }); + }, (response) => { + console.error(response); + dialogHub.closeBusyDialog(); + $scope.$evalAsync(() => { + $scope.state.isBusy = false; + if (response.status === 422 && response.data && response.data.issues) { + $scope.issues = response.data.issues; + } else { + dialogHub.showAlert({ + title: 'Failed to generate', + message: 'Please look at the console for more information', + type: AlertTypes.Error, + preformatted: false, + }); + } + }); + }); + }; + + // ----- Diagram rendering ----------------------------------------------------- + + const render = () => { + if (!window.mermaid || typeof window.mermaid.render !== 'function') { + $scope.diagramSvg = $sce.trustAsHtml('Mermaid not loaded.'); + return; + } + const sections = []; + const erSpec = toErDiagram($scope.model); + if (erSpec) sections.push({ title: 'Entities', spec: erSpec }); + for (const process of $scope.model.processes) { + if (!process || !process.name) continue; + sections.push({ title: 'Process: ' + process.name, spec: toFlowchart(process) }); + } + if (!sections.length) { + $scope.diagramSvg = $sce.trustAsHtml('Nothing to diagram yet - declare entities or processes.'); + return; + } + const generation = ++renderCounter; + Promise.all(sections.map((section, index) => + window.mermaid.render(`intent-editor-svg-${generation}-${index}`, section.spec).then( + (result) => `

    ${escapeHtml(section.title)}

    ${result.svg}`, + (err) => `

    ${escapeHtml(section.title)}

    Render failed: ${escapeHtml(err && err.message ? err.message : String(err))}`) + )).then((rendered) => { + if (generation !== renderCounter) return; // a newer render superseded this one + $scope.diagramSvg = $sce.trustAsHtml(rendered.join('')); + $scope.$applyAsync(); + }); + }; + + const escapeHtml = (s) => String(s).replace(/[&<>'"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[c])); + + const safeName = (s) => String(s || 'UNNAMED').replace(/[^A-Za-z0-9_]/g, '_'); + + // Composition (required to-one) renders as an identifying (solid) relationship, + // association as non-identifying (dashed) - mirroring the EDM generator semantics. + const cardinality = (kind, required) => { + const line = required ? '--' : '..'; + switch (kind) { + case 'oneToMany': return '||' + line + 'o{'; + case 'manyToOne': return '}o' + line + '||'; + case 'oneToOne': return '||' + line + '||'; + case 'manyToMany': return '}o' + line + 'o{'; + default: return '||' + line + 'o{'; + } + }; + + const toErDiagram = (model) => { + const entities = model.entities.filter(e => e && e.name); + if (!entities.length) return null; + const lines = ['erDiagram']; + for (const entity of entities) { + lines.push(' ' + safeName(entity.name) + ' {'); + for (const field of (entity.fields || [])) { + if (!field || !field.name) continue; + const type = (field.type || 'string').replace(/[^A-Za-z0-9_]/g, '_'); + const flag = field.primaryKey ? ' PK' : ''; + lines.push(' ' + type + ' ' + safeName(field.name) + flag); + } + lines.push(' }'); + } + for (const entity of entities) { + for (const relation of (entity.relations || [])) { + if (!relation || !relation.to || !relation.name) continue; + lines.push(' ' + safeName(entity.name) + ' ' + cardinality(relation.kind, relation.required) + + ' ' + safeName(relation.to) + ' : "' + relation.name + '"'); + } + } + return lines.join('\n'); + }; + + // Mirrors BpmnIntentGenerator: a linear chain through the declared steps; decisions + // emit a labeled conditioned edge to `then` and route their default edge to `else` + // (falling back to the next step); `end`-kind steps collapse into the end node. + const toFlowchart = (process) => { + const steps = (process.steps || []).filter(s => s && s.name); + const effectiveId = (name) => { + if (String(name).toLowerCase() === 'end') return 'END'; + const step = steps.find(s => s.name === name); + return (step && String(step.kind).toLowerCase() === 'end') ? 'END' : safeName(name); + }; + const lines = ['flowchart TD', ' START((start))', ' END((end))']; + for (const step of steps) { + if (String(step.kind).toLowerCase() === 'end') continue; + const id = safeName(step.name); + const text = '"' + step.name.replace(/"/g, "'") + '"'; + if (step.kind === 'decision') lines.push(' ' + id + '{' + text + '}'); + else if (step.kind === 'serviceTask' || step.kind === 'script') lines.push(' ' + id + '[[' + text + ']]'); + else lines.push(' ' + id + '[' + text + ']'); + } + const chain = ['START']; + for (const step of steps) { + const id = String(step.kind).toLowerCase() === 'end' ? 'END' : safeName(step.name); + if (chain[chain.length - 1] !== id) chain.push(id); + } + if (chain[chain.length - 1] !== 'END') chain.push('END'); + for (let i = 0; i < chain.length - 1; i++) { + const source = chain[i]; + let target = chain[i + 1]; + const decision = steps.find(s => safeName(s.name) === source && s.kind === 'decision'); + if (decision) { + const args = decision.args || {}; + if (args['else']) target = effectiveId(args['else']); + lines.push(' ' + source + ' -.-> ' + target); + if (args['if'] && args['then']) { + lines.push(' ' + source + ' -- "' + String(args['if']).replace(/"/g, "'") + '" --> ' + effectiveId(args['then'])); + } + } else { + lines.push(' ' + source + ' --> ' + target); + } + } + return lines.join('\n'); + }; + + // ----- Editor lifecycle wiring ----------------------------------------------- + + layoutHub.onFocusEditor((data) => { + if (data.path && data.path === $scope.dataParameters.filePath) statusBarHub.showLabel(''); + }); + + layoutHub.onReloadEditorParams((data) => { + if (data.path === $scope.dataParameters.filePath) { + $scope.$evalAsync(() => { + $scope.dataParameters = ViewParameters.get(); + }); + }; + }); + + workspaceHub.onSaveAll(() => { + if ($scope.changed && !$scope.state.error) { + $scope.save(); + } + }); + + workspaceHub.onSaveFile((data) => { + if (data.path && data.path === $scope.dataParameters.filePath) { + if ($scope.changed && !$scope.state.error) { + $scope.save(); + } + } + }); + + $scope.dataParameters = ViewParameters.get(); + if (!$scope.dataParameters.hasOwnProperty('filePath')) { + $scope.state.error = true; + $scope.errorMessage = 'The \'filePath\' data parameter is missing.'; + $scope.state.isBusy = false; + } else loadFileContents(); +}); diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json similarity index 57% rename from components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json rename to components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json index 2db51c640b..e63dbe064e 100644 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/project.json +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json @@ -1,5 +1,5 @@ { - "guid": "perspective-intent", + "guid": "editor-intent", "dependencies": [], "actions": [] } diff --git a/components/ui/perspective-intent/pom.xml b/components/ui/perspective-intent/pom.xml deleted file mode 100644 index 7e7a734c16..0000000000 --- a/components/ui/perspective-intent/pom.xml +++ /dev/null @@ -1,21 +0,0 @@ - - 4.0.0 - - - org.eclipse.dirigible - dirigible-components-parent - 14.0.0-SNAPSHOT - ../../pom.xml - - - Components - UI - Intent - Perspective - dirigible-components-ui-perspective-intent - jar - - - ../../../licensing-header.txt - ../../../ - - - diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js deleted file mode 100644 index 290c26a9dd..0000000000 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/configs/perspective-menu.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 - */ -exports.getMenu = () => ({ - perspectiveId: 'intent', - include: { - window: true, - help: true, - }, - items: [] -}); diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension deleted file mode 100644 index b50268033d..0000000000 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective-menu.extension +++ /dev/null @@ -1,5 +0,0 @@ -{ - "module": "perspective-intent/configs/perspective-menu.js", - "extensionPoint": "platform-menus", - "description": "Intent perspective menu" -} diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension deleted file mode 100644 index 940fca9fba..0000000000 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/extensions/perspective.extension +++ /dev/null @@ -1,5 +0,0 @@ -{ - "module": "perspective-intent/configs/intent.js", - "extensionPoint": "platform-perspectives", - "description": "Intent Perspective" -} diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg deleted file mode 100644 index 73d841b413..0000000000 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/images/intent.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html b/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html deleted file mode 100644 index 58d1dd4af9..0000000000 --- a/components/ui/perspective-intent/src/main/resources/META-INF/dirigible/perspective-intent/index.html +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/components/ui/view-intent-mermaid/pom.xml b/components/ui/view-intent-mermaid/pom.xml deleted file mode 100644 index e0cf96dc28..0000000000 --- a/components/ui/view-intent-mermaid/pom.xml +++ /dev/null @@ -1,37 +0,0 @@ - - 4.0.0 - - - org.eclipse.dirigible - dirigible-components-parent - 14.0.0-SNAPSHOT - ../../pom.xml - - - Components - UI - Intent - Mermaid View - dirigible-components-ui-view-intent-mermaid - jar - - - - - org.webjars.npm - mermaid - ${mermaid.version} - - - * - * - - - - - - - ../../../licensing-header.txt - ../../../ - - - diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js deleted file mode 100644 index 955bbbae8e..0000000000 --- a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/configs/intent-mermaid-view.js +++ /dev/null @@ -1,22 +0,0 @@ -/* - * 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 - */ -const viewData = { - id: 'intent-mermaid', - region: 'center', - label: 'Diagram', - lazyLoad: true, - autoFocusTab: false, - path: '/services/web/view-intent-mermaid/intent-mermaid.html' -}; -if (typeof exports !== 'undefined') { - exports.getView = () => viewData; -} diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension deleted file mode 100644 index a444b764ac..0000000000 --- a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/extensions/intent-mermaid.extension +++ /dev/null @@ -1,5 +0,0 @@ -{ - "module": "view-intent-mermaid/configs/intent-mermaid-view.js", - "extensionPoint": "platform-views", - "description": "Intent Mermaid View" -} diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html deleted file mode 100644 index 99445c815f..0000000000 --- a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/intent-mermaid.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - - - - - - - - - - - - Intent - - - - - - - - - - - - - No intent projects - Drop an app.intent YAML at a project root and republish. - - - - Unable to load intent - {{errorMessage}} - - -
    -
    -
    {{ sourceYaml }}
    -
    - - - - - - diff --git a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json b/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json deleted file mode 100644 index f6c263bce8..0000000000 --- a/components/ui/view-intent-mermaid/src/main/resources/META-INF/dirigible/view-intent-mermaid/project.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "guid": "view-intent-mermaid", - "dependencies": [], - "actions": [] -} diff --git a/modules/commons/commons-helpers/src/main/java/org/eclipse/dirigible/commons/api/helpers/ContentTypeHelper.java b/modules/commons/commons-helpers/src/main/java/org/eclipse/dirigible/commons/api/helpers/ContentTypeHelper.java index b44e4b7d23..6b7e4fadde 100644 --- a/modules/commons/commons-helpers/src/main/java/org/eclipse/dirigible/commons/api/helpers/ContentTypeHelper.java +++ b/modules/commons/commons-helpers/src/main/java/org/eclipse/dirigible/commons/api/helpers/ContentTypeHelper.java @@ -491,6 +491,9 @@ public class ContentTypeHelper { /** The Constant APPLICATION_JSON_FORM. */ public static final String APPLICATION_JSON_FORM = "application/json+form"; + /** The Constant APPLICATION_YAML_INTENT. */ + public static final String APPLICATION_YAML_INTENT = "application/yaml+intent"; + /** The Constant APPLICATION_JSON_XSACCESS. */ public static final String APPLICATION_JSON_XSACCESS = "application/json+xsaccess"; @@ -718,6 +721,7 @@ public class ContentTypeHelper { TEXT_CONTENT_TYPES.put("csvim", APPLICATION_JSON_CSVIM); //$NON-NLS-1$ TEXT_CONTENT_TYPES.put("command", APPLICATION_JSON_COMMAND); //$NON-NLS-1$ TEXT_CONTENT_TYPES.put("form", APPLICATION_JSON_FORM); //$NON-NLS-1$ + TEXT_CONTENT_TYPES.put("intent", APPLICATION_YAML_INTENT); //$NON-NLS-1$ TEXT_CONTENT_TYPES.put("xsaccess", APPLICATION_JSON_XSACCESS); //$NON-NLS-1$ TEXT_CONTENT_TYPES.put("report", APPLICATION_JSON_REPORT); //$NON-NLS-1$ // TEXT_CONTENT_TYPES.put("entity", APPLICATION_JSON); //$NON-NLS-1$ 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 6810b01598..ac034f89a8 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 @@ -10,20 +10,14 @@ package org.eclipse.dirigible.integration.tests.api; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.hasSize; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; -import java.util.List; -import org.eclipse.dirigible.components.initializers.synchronizer.SynchronizationProcessor; -import org.eclipse.dirigible.components.intent.domain.Intent; -import org.eclipse.dirigible.components.intent.service.IntentService; import org.eclipse.dirigible.repository.api.IRepository; import org.eclipse.dirigible.repository.api.IRepositoryStructure; import org.eclipse.dirigible.repository.api.IResource; @@ -34,44 +28,24 @@ import org.springframework.beans.factory.annotation.Autowired; /** - * End-to-end test for the engine-intent module: drops a comprehensive Orders {@code app.intent} - * into the registry, triggers reconciliation, and asserts the full intent -> model-file pipeline. + * End-to-end test for the intent editor services: {@code POST /services/ide/intent/parse} (the + * editor's live diagram + validation feed) and {@code POST /services/ide/intent/generate} (model + * files generated into the developer's workspace project, with the stale-output scrub). * *

    - * The intent declares five entities (Country / Customer / Product / Order / OrderItem) with - * relations in both directions (including Customer -> Country reference data shaped after - * {@code codbex/codbex-countries}), an OrderApproval process with every step kind (userTask / - * decision / serviceTask / end), two forms bound to entities, a report, three permission roles, and - * a seed block that preloads three rows into {@code COUNTRY} a-la - * {@code codbex/codbex-countries-data}. Every {@code IntentModel} field defined today is exercised. - * The test asserts: - *

      - *
    • the {@link Intent} JPA artefact is persisted via {@link IntentService}
    • - *
    • the {@code /services/ide/intent/*} REST endpoints list / fetch / source / regenerate the - * project
    • - *
    • {@code EdmIntentGenerator} produces a {@code orders.edm} + {@code orders.model} pair - * containing every entity (Country included), every property, and every relation (Customer -> - * Country and Order -> Customer are both wired as references rather than free-text strings)
    • - *
    • {@code BpmnIntentGenerator} produces a {@code OrderApproval.bpmn} with the right BPMN - * elements for each step kind and the conditioned outgoing flow on the decision
    • - *
    • {@code FormIntentGenerator} produces a {@code gen/.form} per form with controls typed - * from the bound entity's fields and action buttons
    • - *
    • {@code ReportIntentGenerator} parses {@code aggregate(field)} measure expressions into - * columns with the right aggregate
    • - *
    • {@code PermissionIntentGenerator} emits the deduped {@code .roles} file
    • - *
    • {@code CsvimIntentGenerator} emits {@code countries.csvim} + {@code countries.csv} with the - * rows in the entity's declared field order
    • - *
    - * - *

    - * HTTP-only - no Selenide, no Chrome. Runs fast enough to be part of the default IT suite. + * The intent is an authoring artifact like the {@code .edm} - there is no synchronizer and nothing + * here touches the registry. Generation is exercised against the {@code admin} user's default + * workspace, exactly as the editor's Generate button does it. HTTP-only - no Selenide, no Chrome, + * no synchronization cycles. */ class IntentEngineIT extends IntegrationTest { - private static final String PROJECT = "orders"; - private static final String INTENT_LOCATION = "/" + PROJECT + "/app.intent"; - private static final String REGISTRY_INTENT = IRepositoryStructure.PATH_REGISTRY_PUBLIC + INTENT_LOCATION; - private static final String REGISTRY_PROJECT = IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT; + private static final String PROJECT = "intent-test"; + private static final String WORKSPACE = "workspace"; + private static final String PROJECT_PATH = IRepositoryStructure.PATH_USERS + "/admin/" + WORKSPACE + "/" + PROJECT; + private static final String PARSE_URL = "/services/ide/intent/parse"; + private static final String GENERATE_URL = + "/services/ide/intent/generate?workspace=" + WORKSPACE + "&project=" + PROJECT + "&path=app.intent"; private static final String INTENT_YAML = """ name: orders @@ -80,59 +54,39 @@ class IntentEngineIT extends IntegrationTest { entities: - name: Country - description: ISO 3166-1 country reference data (shape borrowed from codbex/codbex-countries) + description: ISO 3166-1 country reference data fields: - { name: id, type: integer, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 100 } - { name: code2, type: string, length: 2 } - - { name: code3, type: string, length: 3 } - - { name: numeric, type: string, length: 3 } - name: Customer - description: Buyer account (Partner-style profile, see codbex/codbex-partners) fields: - { name: id, type: uuid, primaryKey: true, generated: true } - { name: name, type: string, required: true, length: 200 } - - { name: email, type: string, length: 200 } - { name: active, type: boolean, defaultValue: "true" } relations: - { name: country, kind: manyToOne, to: Country } - { name: orders, kind: oneToMany, to: Order } - - name: Product - description: Product catalog entry - fields: - - { name: id, type: uuid, primaryKey: true, generated: true } - - { name: name, type: string, required: true, length: 200 } - - { name: description, type: text } - - { name: price, type: decimal, required: true } - - { name: inStock, type: boolean } - - name: Order - description: Customer order fields: - { name: id, type: uuid, primaryKey: true, generated: true } - { name: orderDate, type: date, required: true } - - { name: status, type: string, length: 32 } - { name: total, type: decimal } relations: - { name: customer, kind: manyToOne, to: Customer } - { name: items, kind: oneToMany, to: OrderItem } - name: OrderItem - description: Line item in an order fields: - - { name: id, type: uuid, primaryKey: true, generated: true } - - { name: quantity, type: integer, required: true } - - { name: lineTotal, type: decimal } + - { name: id, type: uuid, primaryKey: true, generated: true } + - { name: quantity, type: integer, required: true } relations: - - { name: order, kind: manyToOne, to: Order, required: true } - - { name: product, kind: manyToOne, to: Product, required: true } + - { name: order, kind: manyToOne, to: Order, required: true } processes: - name: OrderApproval - description: Manager approval for orders above 10000 - trigger: { onCreate: Order } steps: - name: managerReview kind: userTask @@ -153,292 +107,232 @@ class IntentEngineIT extends IntegrationTest { - name: ApproveOrder forEntity: Order description: Approve or reject an order - fields: [orderDate, status, total] + fields: [orderDate, total] actions: [approve, reject] - - name: NewCustomer - forEntity: Customer - description: Create a new customer - fields: [name, email, country, active] - actions: [save, cancel] - reports: - name: OrdersByCustomer source: Order - dimensions: [customer.country] + dimensions: [customer] measures: ["count(*)", "sum(total)"] permissions: - - { role: Sales, description: Sales staff, can: [Customer:read, Customer:write, Order:read, Order:create] } - - { role: Manager, description: Sales manager, can: [Order:approve, Order:read] } - - { role: Administrator, description: System admin, can: [Customer:write, Product:write, Order:write] } + - { role: Sales, description: Sales staff, can: [Customer:read, Order:create] } + - { role: Manager, description: Sales manager, can: [Order:approve] } seeds: - name: countries entity: Country - description: Sample ISO 3166-1 rows (shape borrowed from codbex/codbex-countries-data) rows: - - { id: 1, name: Afghanistan, code2: AF, code3: AFG, numeric: "004" } - - { id: 2, name: Albania, code2: AL, code3: ALB, numeric: "008" } - - { id: 3, name: Algeria, code2: DZ, code3: DZA, numeric: "012" } + - { id: 1, name: Afghanistan, code2: AF } + - { id: 2, name: Albania, code2: AL } """; @Autowired private IRepository repository; @Autowired - private SynchronizationProcessor synchronizationProcessor; + private RestAssuredExecutor restAssuredExecutor; - @Autowired - private IntentService intentService; + @Test + void parse_returns_the_full_model() { + restAssuredExecutor.execute(() -> given().contentType("text/plain") + .body(INTENT_YAML) + .when() + .post(PARSE_URL) + .then() + .statusCode(200) + .body("name", equalTo("orders")) + .body("entities", hasSize(4)) + .body("entities.name", hasItems("Country", "Customer", "Order", "OrderItem")) + .body("processes", hasSize(1)) + .body("processes[0].steps", hasSize(5)) + .body("forms", hasSize(1)) + .body("reports", hasSize(1)) + .body("permissions", hasSize(2)) + .body("seeds[0].rows", hasSize(2))); + } - @Autowired - private RestAssuredExecutor restAssuredExecutor; + @Test + void parse_reports_every_validation_issue_at_once() { + String broken = """ + name: broken + entities: + - name: Customer + fields: + - { name: id, type: uuid, primaryKey: true } + relations: + - { name: country, kind: manyToOne, to: Nowhere } + processes: + - name: Flow + steps: + - name: decide + kind: decision + args: { if: "x > 1", then: missingStep } + """; + restAssuredExecutor.execute(() -> given().contentType("text/plain") + .body(broken) + .when() + .post(PARSE_URL) + .then() + .statusCode(422) + .body("issues", hasSize(2)) + .body("issues", hasItems( + "entity [Customer] relation [country] points to unknown entity [Nowhere]", + "process [Flow] decision [decide] `then` references unknown step [missingStep]"))); + } @Test - void full_intent_pipeline_generates_all_model_files() { - repository.createResource(REGISTRY_INTENT, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); - synchronizationProcessor.forceProcessSynchronizers(); - - assertIntentIsPersisted(); - assertEdmAndModelGenerated(); - assertBpmnGenerated(); - assertFormGenerated(); - assertReportGenerated(); - assertRolesGenerated(); - assertSeedsGenerated(); - assertRestEndpoints(); + void generate_writes_all_model_files_into_the_workspace_project() { + writeIntent(INTENT_YAML); + + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200) + .body("project", equalTo(PROJECT)) + .body("written", + hasItems("orders.edm", "orders.model", "OrderApproval.bpmn", "ApproveOrder.form", + "OrdersByCustomer.report", "orders.roles", "countries.csvim", + "countries.csv")) + .body("scrubbed", hasSize(0))); + + assertEdmAndModel(); + assertBpmn(); + assertForm(); + assertReport(); + assertRoles(); + assertSeeds(); } @Test - void regeneration_scrubs_stale_gen_files() { - repository.createResource(REGISTRY_INTENT, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); - synchronizationProcessor.forceProcessSynchronizers(); - assertTrue(repository.getResource(REGISTRY_PROJECT + "/countries.csvim") - .exists(), - "seed output should exist after the first publish"); - - String withoutSeeds = INTENT_YAML.substring(0, INTENT_YAML.indexOf("seeds:")); - repository.getResource(REGISTRY_INTENT) - .setContent(withoutSeeds.getBytes(StandardCharsets.UTF_8)); - synchronizationProcessor.forceProcessSynchronizers(); - - assertTrue(!repository.getResource(REGISTRY_PROJECT + "/countries.csvim") - .exists(), - "removing the seed block from the intent should scrub the stale .csvim on regeneration"); - assertTrue(!repository.getResource(REGISTRY_PROJECT + "/countries.csv") - .exists(), - "removing the seed block from the intent should scrub the stale .csv on regeneration"); - assertTrue(repository.getResource(REGISTRY_PROJECT + "/orders.edm") - .exists(), - "still-declared slices must survive the scrub"); + void regeneration_scrubs_stale_model_files() { + writeIntent(INTENT_YAML); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + assertTrue(resource("countries.csvim").exists(), "seed output should exist after the first generation"); + + writeIntent(INTENT_YAML.substring(0, INTENT_YAML.indexOf("seeds:"))); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200) + .body("scrubbed", hasItems("countries.csvim", "countries.csv"))); + + assertTrue(!resource("countries.csvim").exists(), "removing the seed block should scrub the stale .csvim"); + assertTrue(!resource("countries.csv").exists(), "removing the seed block should scrub the stale .csv"); + assertTrue(resource("orders.edm").exists(), "still-declared slices must survive the scrub"); + assertTrue(resource("app.intent").exists(), "the scrub must never touch the intent source itself"); } @Test - void intent_removal_cleans_artefact_and_gen_output() { - repository.createResource(REGISTRY_INTENT, INTENT_YAML.getBytes(StandardCharsets.UTF_8)); - synchronizationProcessor.forceProcessSynchronizers(); - assertTrue(intentService.findByLocation(INTENT_LOCATION) - .stream() - .findFirst() - .isPresent(), - "intent should be persisted after publish"); - assertTrue(repository.getResource(REGISTRY_PROJECT + "/orders.edm") - .exists(), - "model output should exist after publish"); - - repository.removeResource(REGISTRY_INTENT); - synchronizationProcessor.forceProcessSynchronizers(); - - assertTrue(intentService.findByLocation(INTENT_LOCATION) - .isEmpty(), - "intent should be cleaned up after the .intent file is removed and the synchronizer runs"); - assertTrue(!repository.getResource(REGISTRY_PROJECT + "/orders.edm") - .exists(), - "intent-owned model files should not survive their source of truth"); - assertTrue(!repository.getResource(REGISTRY_PROJECT + "/OrderApproval.bpmn") - .exists(), - "per-process model files should be scrubbed on intent removal"); + void generate_rejects_invalid_intents_with_the_issue_list() { + writeIntent("entities:\n - name: A\n fields:\n - { name: x, type: nosuchtype }\n"); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(422) + .body("issues", hasItem("entity [A] field [x] has unknown type [nosuchtype]"))); } - private void assertIntentIsPersisted() { - List matches = intentService.findByLocation(INTENT_LOCATION); - assertTrue(!matches.isEmpty(), "intent should be persisted at location " + INTENT_LOCATION); - Intent intent = matches.get(0); - assertNotNull(intent.getContent(), "intent payload should be persisted"); - assertTrue(intent.getContent() - .contains("entities:"), - "intent content should contain the YAML body, not just an empty record"); + private void writeIntent(String yaml) { + String path = PROJECT_PATH + "/app.intent"; + IResource existing = repository.getResource(path); + if (existing.exists()) { + existing.setContent(yaml.getBytes(StandardCharsets.UTF_8)); + } else { + repository.createResource(path, yaml.getBytes(StandardCharsets.UTF_8)); + } + } + + private IResource resource(String fileName) { + return repository.getResource(PROJECT_PATH + "/" + fileName); } - private void assertEdmAndModelGenerated() { - IResource edm = repository.getResource(REGISTRY_PROJECT + "/orders.edm"); - assertTrue(edm.exists(), "orders.edm should be generated"); - String edmXml = new String(edm.getContent(), StandardCharsets.UTF_8); - for (String entityName : List.of("Country", "Customer", "Product", "Order", "OrderItem")) { + private String contentOf(String fileName) { + return new String(resource(fileName).getContent(), StandardCharsets.UTF_8); + } + + private void assertEdmAndModel() { + assertTrue(resource("orders.edm").exists(), "orders.edm should be generated"); + String edmXml = contentOf("orders.edm"); + for (String entityName : new String[] {"Country", "Customer", "Order", "OrderItem"}) { assertTrue(edmXml.contains("name=\"" + entityName + "\""), "EDM should declare entity [" + entityName + "]"); } - assertTrue(edmXml.contains("dataPrimaryKey=\"true\""), "EDM should mark at least one property as primary key"); - assertTrue(edmXml.contains("widgetType=\"NUMBER\""), "EDM should map decimal/integer fields to a NUMBER widget"); - assertTrue(edmXml.contains("widgetType=\"DATE\""), "EDM should map the date field to a DATE widget"); - assertTrue(edmXml.contains("widgetType=\"CHECKBOX\""), "EDM should map the boolean field to a CHECKBOX widget"); - assertTrue(edmXml.contains("widgetType=\"TEXTAREA\""), "EDM should map the text field to a TEXTAREA widget"); - assertTrue(edmXml.contains("type=\"PRIMARY\""), "EDM should declare at least one PRIMARY entity"); - assertTrue(edmXml.contains("type=\"DEPENDENT\""), - "EDM should mark OrderItem as DEPENDENT through its required (composition) manyToOne to Order"); assertTrue(edmXml.contains("dataName=\"ORDERS_ORDER\""), "EDM dataName should be intent-prefixed (ORDERS_ORDER) to avoid reserved words and cross-project clashes"); + assertTrue(edmXml.contains("type=\"DEPENDENT\""), + "EDM should mark OrderItem as DEPENDENT through its required (composition) manyToOne to Order"); assertTrue(edmXml.contains("widgetDropDownKey=\"id\""), "dropdown key should be the target entity's actual PK field name (lowercase id), not a hardcoded Id"); - assertTrue(edmXml.contains("referencedProperty=\"id\""), "relation referencedProperty should be the target's actual PK field name"); - assertTrue(edmXml.contains("referenced=\"Country\""), "EDM should carry the Customer->Country reference relation"); assertTrue(edmXml.contains("referenced=\"Customer\""), "EDM should carry the Order->Customer relation"); - assertTrue(edmXml.contains("referenced=\"Order\""), "EDM should carry the OrderItem->Order relation"); - assertTrue(edmXml.contains("referenced=\"Product\""), "EDM should carry the OrderItem->Product relation"); assertTrue(edmXml.contains("dataName=\"CUSTOMER_COUNTRY\""), "Customer->Country FK should materialize as a CUSTOMER_COUNTRY column on Customer"); - IResource modelJson = repository.getResource(REGISTRY_PROJECT + "/orders.model"); - assertTrue(modelJson.exists(), "orders.model should be generated"); - String modelBody = new String(modelJson.getContent(), StandardCharsets.UTF_8); + assertTrue(resource("orders.model").exists(), "orders.model should be generated"); + String modelBody = contentOf("orders.model"); assertTrue(modelBody.contains("\"entities\""), "model JSON should have an entities array"); assertTrue(modelBody.contains("\"perspectives\""), "model JSON should carry the perspectives array like editor-written files"); assertTrue(modelBody.contains("\"navigations\""), "model JSON should carry the navigations array like editor-written files"); - assertTrue(modelBody.contains("\"OrderItem\""), "model JSON should mention OrderItem"); } - private void assertBpmnGenerated() { - IResource bpmn = repository.getResource(REGISTRY_PROJECT + "/OrderApproval.bpmn"); - assertTrue(bpmn.exists(), "OrderApproval.bpmn should be generated"); - String body = new String(bpmn.getContent(), StandardCharsets.UTF_8); - assertTrue(body.contains(" 10000}"), "BPMN should embed the decision's if expression"); assertTrue(body.contains("id=\"flow_bigOrder_then\" sourceRef=\"bigOrder\" targetRef=\"cfoReview\""), "the conditioned flow should target the `then` step"); assertTrue(body.contains("id=\"flow_bigOrder_default\" sourceRef=\"bigOrder\" targetRef=\"notifyCustomer\""), "the gateway default flow should target the `else` step so small orders skip CFO review"); + assertTrue(body.contains("${amount > 10000}"), "BPMN should embed the decision's if expression"); } - private void assertFormGenerated() { - IResource form = repository.getResource(REGISTRY_PROJECT + "/ApproveOrder.form"); - assertTrue(form.exists(), "ApproveOrder.form should be generated"); - String body = new String(form.getContent(), StandardCharsets.UTF_8); + private void assertForm() { + String body = contentOf("ApproveOrder.form"); assertTrue(body.contains("\"controlId\": \"header\""), "form should start with a header control"); - assertTrue(body.contains("\"label\": \"Order Date\""), "form should humanize the orderDate field name to 'Order Date'"); assertTrue(body.contains("\"controlId\": \"input-date\""), "form should pick input-date for the orderDate field"); assertTrue(body.contains("\"controlId\": \"input-number\""), "form should pick input-number for the total decimal field"); assertTrue(body.contains("\"type\": \"positive\""), "form should mark the approve button as positive"); - assertTrue(body.contains("\"type\": \"negative\""), "form should mark the reject button as negative"); assertTrue(body.contains("onApproveClicked"), "form code should declare the approve handler stub"); - - IResource customerForm = repository.getResource(REGISTRY_PROJECT + "/NewCustomer.form"); - assertTrue(customerForm.exists(), "NewCustomer.form should be generated"); - String customerBody = new String(customerForm.getContent(), StandardCharsets.UTF_8); - assertTrue(customerBody.contains("\"controlId\": \"input-checkbox\""), - "customer form should map active:boolean to an input-checkbox"); } - private void assertReportGenerated() { - IResource report = repository.getResource(REGISTRY_PROJECT + "/OrdersByCustomer.report"); - assertTrue(report.exists(), "OrdersByCustomer.report should be generated"); - String body = new String(report.getContent(), StandardCharsets.UTF_8); + private void assertReport() { + String body = contentOf("OrdersByCustomer.report"); assertTrue(body.contains("\"alias\": \"OrdersByCustomer\""), "report should carry the intent name as alias"); assertTrue(body.contains("\"baseTable\": \"ORDERS_ORDER\""), "report baseTable should carry the same intent-prefixed table name the EDM declares as dataName"); - assertTrue(body.contains("\"aggregate\": \"NONE\""), "dimensions should be emitted with aggregate NONE"); assertTrue(body.contains("\"aggregate\": \"COUNT\""), "count(*) should be parsed into an aggregate COUNT column"); assertTrue(body.contains("\"aggregate\": \"SUM\""), "sum(total) should be parsed into an aggregate SUM column"); - assertTrue(body.contains("\"name\": \"customer.country\""), - "dotted dimension paths should be preserved verbatim in the column name"); - assertTrue(body.contains("\"name\": \"total\""), "sum(total) measure should resolve to a column whose name is 'total'"); } - private void assertSeedsGenerated() { - IResource csvim = repository.getResource(REGISTRY_PROJECT + "/countries.csvim"); - assertTrue(csvim.exists(), "countries.csvim should be generated"); - String csvimBody = new String(csvim.getContent(), StandardCharsets.UTF_8); - assertTrue(csvimBody.contains("\"table\": \"ORDERS_COUNTRY\""), - "csvim should target the same intent-prefixed table name the EDM declares as dataName"); - assertTrue(csvimBody.contains("\"schema\": \"PUBLIC\""), "csvim should default the schema to PUBLIC"); - assertTrue(csvimBody.contains("\"file\": \"/orders/countries.csv\""), - "csvim file path must be project-qualified - CsvimProcessor resolves it against /registry/public"); - assertTrue(csvimBody.contains("\"header\": true"), "csvim should declare a header row"); - assertTrue(csvimBody.contains("\"useHeaderNames\": true"), "csvim should use header names for column mapping"); - - IResource csv = repository.getResource(REGISTRY_PROJECT + "/countries.csv"); - assertTrue(csv.exists(), "countries.csv should be generated"); - String csvBody = new String(csv.getContent(), StandardCharsets.UTF_8); - assertTrue(csvBody.startsWith("COUNTRY_ID,COUNTRY_NAME,COUNTRY_CODE2,COUNTRY_CODE3,COUNTRY_NUMERIC"), - "csv header should carry the upper-snake column names in entity-field order"); - assertTrue(csvBody.contains("1,Afghanistan,AF,AFG,004"), "csv should include the Afghanistan row"); - assertTrue(csvBody.contains("2,Albania,AL,ALB,008"), "csv should include the Albania row"); - assertTrue(csvBody.contains("3,Algeria,DZ,DZA,012"), "csv should include the Algeria row"); - } - - private void assertRolesGenerated() { - IResource roles = repository.getResource(REGISTRY_PROJECT + "/orders.roles"); - assertTrue(roles.exists(), "orders.roles should be generated"); - String body = new String(roles.getContent(), StandardCharsets.UTF_8); + private void assertRoles() { + String body = contentOf("orders.roles"); assertTrue(body.contains("\"name\": \"Sales\""), "Sales role should be present"); assertTrue(body.contains("\"name\": \"Manager\""), "Manager role should be present"); - assertTrue(body.contains("\"name\": \"Administrator\""), "Administrator role should be present"); assertTrue(body.contains("\"description\": \"Sales staff\""), "Role descriptions should be carried through"); } - private void assertRestEndpoints() { - restAssuredExecutor.execute(() -> given().when() - .get("/services/ide/intent/projects") - .then() - .statusCode(200) - .body("$", hasItem(PROJECT))); - - restAssuredExecutor.execute(() -> given().when() - .get("/services/ide/intent/projects/" + PROJECT) - .then() - .statusCode(200) - .body("entities", hasSize(greaterThanOrEqualTo(5))) - .body("entities.name", hasItem("Country")) - .body("entities.name", hasItem("Customer")) - .body("entities.name", hasItem("OrderItem")) - .body("processes", hasSize(1)) - .body("processes[0].name", equalTo("OrderApproval")) - .body("processes[0].steps", hasSize(5)) - .body("forms", hasSize(2)) - .body("reports", hasSize(1)) - .body("permissions", hasSize(3)) - .body("seeds", hasSize(1)) - .body("seeds[0].entity", equalTo("Country")) - .body("seeds[0].rows", hasSize(3))); - - restAssuredExecutor.execute(() -> given().when() - .get("/services/ide/intent/projects/" + PROJECT + "/source") - .then() - .statusCode(200) - .body(containsString("name: orders")) - .body(containsString("OrderApproval"))); + private void assertSeeds() { + String csvimBody = contentOf("countries.csvim"); + assertTrue(csvimBody.contains("\"table\": \"ORDERS_COUNTRY\""), + "csvim should target the same intent-prefixed table name the EDM declares as dataName"); + assertTrue(csvimBody.contains("\"file\": \"/" + PROJECT + "/countries.csv\""), + "csvim file path must be project-qualified - CsvimProcessor resolves it against /registry/public"); - restAssuredExecutor.execute(() -> given().when() - .post("/services/ide/intent/projects/" + PROJECT + "/regenerate") - .then() - .statusCode(200) - .body("project", equalTo(PROJECT)) - .body("status", equalTo("regenerated"))); + String csvBody = contentOf("countries.csv"); + assertTrue(csvBody.startsWith("COUNTRY_ID,COUNTRY_NAME,COUNTRY_CODE2"), + "csv header should carry the upper-snake column names in entity-field order"); + assertTrue(csvBody.contains("1,Afghanistan,AF"), "csv should include the Afghanistan row with an integral id"); } @AfterEach - void removeIntentFromRegistry() { - if (repository.hasResource(REGISTRY_INTENT)) { - repository.removeResource(REGISTRY_INTENT); - synchronizationProcessor.forceProcessSynchronizers(); + void removeProject() { + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); } } } From 98a97c9b92e18aa257dde5bd95e2679516e14691 Mon Sep 17 00:00:00 2001 From: delchev Date: Sat, 13 Jun 2026 10:31:24 +0300 Subject: [PATCH 13/28] editor-intent: load the editor config and ng-editor platform links Double-clicking a .intent file routed to the editor (content type maps correctly) but the page failed to bootstrap with two errors: - view.js "You must provide one of the following: ... editorData": the page never loaded its own configs/intent-editor.js, so the platform view framework had no editorData to read. - intentEditor $injector:modulerr: the page requested only the ng-view platform-links category, so WorkspaceService / ViewParameters / the workspace hubs (provided by ng-editor) were absent and the Angular module could not satisfy its dependencies. Both are required of every workspace editor; the editor.html now loads configs/intent-editor.js in the head and requests "ng-view,ng-editor", matching the csvim/form editors. Verified against a running instance: the injected HTML now pulls intent-editor.js, view.js, workspace.js and workspace-hub.js. Co-Authored-By: Claude Fable 5 --- .../resources/META-INF/dirigible/editor-intent/editor.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/editor.html b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/editor.html index c5da2073ca..ed4f5c5b24 100644 --- a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/editor.html +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/editor.html @@ -19,8 +19,9 @@ + - +