diff --git a/CLAUDE.md b/CLAUDE.md index 44edd752d20..1ffdb763c67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,14 @@ Client `.java` sources dropped under `/registry/public//...` ARE synchr - **HBM XML serializer is reused** from `data-store`: `JavaEntityToHbmMapper` (in `data-store-java`) reflects over annotations and feeds `HbmXmlDescriptor` / `HbmPropertyDescriptor` from `data-store`. If you change the HBM serializer for one, audit both. - **Reaching platform beans from client code.** Client classes are loaded via `ClientClassLoader`, not Spring-scanned, so `@Autowired` is a no-op on them. Use `BeanProvider.getBean(...)` (from `components-core-base`) inside controller / handler methods to fetch `JavaEntityStore`, `IRepository`, etc. The recommended client-code pattern is `@Inject CountryRepository` — see [`dirigiblelabs/sample-java-entity-decorators`](https://github.com/dirigiblelabs/sample-java-entity-decorators); `JavaEntityDecoratorsSampleProjectIT` clones and exercises that repo end-to-end. +## Intent layer (`engine-intent` + `editor-intent`) + +A single `app.intent` YAML file at a project root is the source of truth one altitude above the model files. **The intent is an authoring artifact, not a runtime artifact** — like the `.edm` it has an editor and an explicit Generate, and (like the `.edm`) it has **no synchronizer**. Double-clicking any `*.intent` file opens the Intent Editor (`components/ui/editor-intent`): editable YAML left, live read-only diagram right (mxGraph ER + per-process flowcharts, the same engine the EDM/schema/mapping modelers use), validation inline; the Generate button runs six generators that write `.edm`/`.model`, `.bpmn`, `.form`, `.report`, `.roles` and `.csvim`/`.csv` **into the developer's workspace project at the project root** (the layout of real-world codbex application projects) — nothing touches the registry until normal publish, after which the per-artefact synchronizers bring the runtime live as for any project. Services: `POST /services/ide/intent/parse` and `POST /services/ide/intent/generate`. Developed on PR [#6017](https://github.com/eclipse-dirigible/dirigible/pull/6017). + +**Detailed guide:** [`components/engine/engine-intent/CLAUDE.md`](components/engine/engine-intent/CLAUDE.md). Read it before changing anything under that module — it covers the editor-first architecture and altitude contract (model files only, never code), the YAML schema and its semantics (integer-only primary keys, `composition: true` to-one = DEPENDENT master-detail while `required` alone is just a NOT NULL FK, PascalCase property names with UPPER_SNAKE columns, decision `then`/`else`, intent-prefixed table names via `IntentNaming`), the `writeModelFile`-only write surface with the stale-output scrub, the wrong turns already made (wrong altitude, template-output paths, registry-relative vs repository-absolute paths, the `JsonHelper` Gson pitfall, **and the synchronizer-based first incarnation — do not reintroduce it**), and the follow-up list (chaining model-to-code via `.gen` descriptors, `/custom/` escape hatch, trigger wiring). `IntentEngineIT` is the HTTP-only end-to-end test (~1 minute, no sync cycles). The editor's diagram pane is **mxGraph** (replacing Mermaid, which had unfixable light/dark theming bugs) with a fixed brand-colour palette that reads on both themes — see the module guide's "Intent Editor diagram = mxGraph" section before touching `editor-intent/js/editor.js`. + +**The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. + ## Native applications (`engine-native-apps`) User projects can declare a `*.nativeapp` JSON file that turns an external web server — local OS process or remote HTTP(S) endpoint — into a first-class Dirigible artefact, reverse-proxied under `/services/native-apps-proxy/v1//...` with optional Dirigible-managed authentication and role-based access. The whole feature lives in `components/engine/engine-native-apps`. @@ -206,6 +214,7 @@ User projects can declare a `*.nativeapp` JSON file that turns an external web s Default to writing concise Javadoc on every `public` SDK / API class and method (one sentence is fine), since these are the surfaces user projects link against and the only ones whose docs get published. Internal Spring beans, IDE plumbing, and tests don't need it — comments on intent over comments on shape, per the global rule. - **License headers are checked.** Every Java/JS/properties file carries the EPL-2.0 header. New files should include it; `mvn license:format -P license` will add or refresh it. Most local-iteration profiles set `license.skip=true`, but the default `mvn install` does not. - **Integration tests boot the full Spring app and drive Chrome via Selenide.** Pass `-D selenide.headless=true` to run them on CI/headless machines. Screenshots end up in `tests/tests-integrations/build/reports/tests`. +- **UI module resources are bundled into the `build/application` fat jar — a running instance does NOT hot-reload them.** After editing anything under `components/ui/*` or other `META-INF/dirigible/**` web resources, you must `mvn -P quick-build install -pl ` **and** `mvn -P quick-build package -pl build/application`, then restart `java -jar build/application/target/...jar`, before a locally running instance reflects the change. Integration tests repackage `tests-integrations` independently, so a **green IT never proves the user's running jar is current** — when a user reports "nothing changed," verify the served resource first (e.g. `curl -s -u admin:admin http://localhost:8080/services/web//...js | grep `). - **DB-specific behavior is covered by parametrized CI.** `build.yml` runs the integration suite three times — H2 (default), PostgreSQL 16, and MSSQL 2022 — by varying the `DIRIGIBLE_DATASOURCE_DEFAULT_*` env vars. When touching SQL or schema-emission code, replicate this locally for the affected DB rather than assuming H2 behavior generalizes. - **WebJars / `dirigiblelabs` modules.** Some IDE-side modules (names starting with `ide-`, `api-`, `ext-`) historically lived as separate repos under [dirigiblelabs](https://github.com/dirigiblelabs); per `CONTRIBUTING.md` step 8 you may need `mvn clean install -Pcontent` to pull their latest content if working across them. - **`*IT.java` vs `*Test.java` matters** — failsafe picks up the former, surefire the latter. Putting an integration test under a `*Test` name will silently run it during the wrong phase (and likely without the test app context). @@ -215,8 +224,11 @@ User projects can declare a `*.nativeapp` JSON file that turns an external web s - **JPA scan packages are `{ "org.eclipse.dirigible.components", "org.eclipse.dirigible.engine" }`** in `DataSourceSystemConfig` (both `@EnableJpaRepositories(basePackages = …)` and the `dirigible.scan.packages` property default). New artefact-bearing modules under either tree are picked up automatically; modules under other roots won't be. - **Cross-module FK cascades use `@OnDelete(action = OnDeleteAction.CASCADE)`.** `core-tenants` depends on `engine-security` (for `Role`), not the reverse, so a JPA-level bidirectional cascade from `Role → UserRoleAssignment` would form a module cycle. `UserRoleAssignment.role` instead carries `@org.hibernate.annotations.OnDelete(action = OnDeleteAction.CASCADE)`, which makes Hibernate emit `ON DELETE CASCADE` on the FK at schema-generation time and the DB drops the assignment row when its role is deleted. Without this, removing a `.roles` artefact whose role was still assigned to a user tripped `FK… DIRIGIBLE_USER_ROLE_ASSIGNMENTS → DIRIGIBLE_SECURITY_ROLES` and the role row survived in the DB out of sync with its missing source file. `RoleSynchronizerCleanupIT` covers it end-to-end. **Caveat for migrations:** Hibernate only emits the cascade when the FK is *created*; existing deployments keep the old un-cascading constraint until the table is rebuilt. Apply the same pattern for any new artefact-bearing entity that owns an FK to a synchronizer-managed parent. - **Spring Boot 3 fat-jar classpath is fragile.** Reading `BOOT-INF/lib/*.jar` resources via `ClassLoader.getResourceAsStream` — or via aggressive scanners like ClassGraph — closes pooled `NestedJarFile` handles inside `LaunchedClassLoader` and causes cascading `NoClassDefFoundError`s in unrelated platform code. If you need the platform classpath (e.g. as input to `javac --class-path`), follow `engine-java.runtime.ClassPathIndex`: open the outer fat jar with the standard `java.util.jar.JarFile`, extract `BOOT-INF/lib/*.jar` + `BOOT-INF/classes/` to a temp dir once, and use those on-disk paths. Don't introspect nested jars in-process. -- **HTTP-driven integration tests are an option.** Most existing ITs extend `UserInterfaceIntegrationTest` and drive the IDE via Selenide — heavy and slow. For a feature you can exercise purely over HTTP, extend `IntegrationTest` instead, write fixture files directly through `IRepository.createResource(...)`, call `SynchronizationProcessor.forceProcessSynchronizers()` to trigger reconciliation synchronously, and assert via `RestAssuredExecutor.execute(callable, timeoutSeconds)` (the retry-on-AssertionError overload absorbs the small async gap between sync-return and dispatch). See `JavaEngineIT` for the pattern; it runs headless without Chrome. -- **Sample-project tests live in `tests/ui/tests/sample/`** — each extends `SampleProjectRepositoryIT` and overrides `getRepositoryURL()` + `verifyProject()`. `SampleProjectRepositoryIT` clones the repo through the IDE Git perspective, calls `Workbench.publishAll(true)`, and runs `forceProcessSynchronizers()` before delegating to `verifyProject()`. UI-based (Selenide → Chrome), slower than HTTP-only ITs. Inventory of sample repos under `dirigiblelabs/*`: `sample-entity-decorators`, `sample-java-entity-decorators`, `sample-roles-decorator`, `sample-job-decorator`, `sample-listener-decorator`, `sample-extension-decorator`, `sample-component-decorator`, `sample-websocket-decorator`, `sample-store-api`. When adding a new sample-project IT, drop the project in its own `dirigiblelabs/*` repo first, then reference the clone URL from the test. +- **HTTP-driven integration tests are an option.** Most existing ITs extend `UserInterfaceIntegrationTest` and drive the IDE via Selenide — heavy and slow. For a feature you can exercise purely over HTTP, extend `IntegrationTest` instead, write fixture files directly through `IRepository.createResource(...)`, call `SynchronizationProcessor.forceProcessSynchronizers()` to trigger reconciliation synchronously, and assert via `RestAssuredExecutor.execute(callable, timeoutSeconds)` (the retry-on-AssertionError overload absorbs the small async gap between sync-return and dispatch). See `JavaEngineIT` for the pattern; it runs headless without Chrome. `forceProcessSynchronizers()` is reliable since the engine-intent PR: it used to silently no-op when the scheduled `SynchronizationJob` was mid-run (the root cause of `LocalNativeAppLifecycleIT`-style flakes); it now retries until a full pass has actually consumed the force flag (bounded at 5 minutes). +- **Synchronizer artefact locations are registry-relative; `IRepository` paths are repository-absolute.** `SynchronizationWalker` strips the registry folder, so an artefact's `location` looks like `/myproject/file.ext` — but to read or write that file through `IRepository` you must prepend `IRepositoryStructure.PATH_REGISTRY_PUBLIC`. Code that confuses the two writes outside `/registry/public`, where no synchronizer, IT assertion, or Registry view will ever see the output (engine-intent shipped this bug once — see its CLAUDE.md "wrong turns"). The same convention shows up in `SynchronizationProcessor`'s cleanup pass and `CsvimProcessor.getCsvResource`. +- **`JsonHelper`/`GsonHelper` exclude fields without `@Expose` — and pretty-print.** Their Gson is built with `excludeFieldsWithoutExposeAnnotation()`, so `fromJson` into a POJO whose fields lack `@Expose` silently produces an all-null object (no exception — engine-intent's parser "worked" while generating nothing). Map-shaped data is unaffected (maps aren't field-reflected). For POJO mapping either annotate every field or use a private plain `Gson`; when asserting generated JSON content in tests remember the output is pretty-printed (`"key": "value"` with a space). +- **Stale H2 files break IT runs after a killed JVM.** The file-backed H2 under `tests/tests-integrations/target/dirigible` survives a killed test run and the next context fails to load with `missing artefact with name: [DefaultDB]` or `Table ... not found (this database is empty)` cascades. `rm -rf tests/tests-integrations/target/dirigible` before re-running locally. +- **Sample-project tests live in `tests/ui/tests/sample/`** — each extends `SampleProjectRepositoryIT` and overrides `getRepositoryURL()` + `verifyProject()`. `SampleProjectRepositoryIT` clones the repo through the IDE Git perspective, calls `Workbench.publishAll(true)`, and runs `forceProcessSynchronizers()` before delegating to `verifyProject()`. UI-based (Selenide → Chrome), slower than HTTP-only ITs. Inventory of sample repos under `dirigiblelabs/*`: `sample-entity-decorators`, `sample-java-entity-decorators`, `sample-roles-decorator`, `sample-job-decorator`, `sample-listener-decorator`, `sample-extension-decorator`, `sample-component-decorator`, `sample-websocket-decorator`, `sample-store-api`, `sample-intent-model`. When adding a new sample-project IT, drop the project in its own `dirigiblelabs/*` repo first, then reference the clone URL from the test. `IntentEditorLoadsIT` is not a `SampleProjectRepositoryIT` subclass (the intent generates in the workspace, not on publish) but uses the same clone-a-real-repo pattern: it clones `dirigiblelabs/sample-intent-model`, opens its `app.intent`, and drives the editor's Generate. - **Spring Boot 4 strips `ResponseStatusException.getReason()`** from the default JSON error body even with `server.error.include-message=always` in `application-common.properties`. Status code reaches the client correctly; the reason text doesn't. ITs asserting 404 must check `statusCode` only, not body content (`JavaEngineIT.delete_unregisters_handler` and `compile_error_keeps_endpoint_unregistered` learned this the hard way — see commit `f13c8c219e`). - **The client-Java effort is split across three repositories.** The platform code (engine-java, data-store-java, IT) ships in this repo via PR [#5923](https://github.com/eclipse-dirigible/dirigible/pull/5923). The sample project that `JavaEntityDecoratorsSampleProjectIT` clones lives in [`dirigiblelabs/sample-java-entity-decorators`](https://github.com/dirigiblelabs/sample-java-entity-decorators) (initial content from PR [#1](https://github.com/dirigiblelabs/sample-java-entity-decorators/pull/1)). The announcement blog "Return of the Java – Decorators Awaken in Eclipse Dirigible" (sister piece to the December 2025 TS decorators post) goes through the docs portal in PR [`dirigible-io/dirigible-io.github.io#123`](https://github.com/dirigible-io/dirigible-io.github.io/pull/123). Follow-up Java-runtime features generally touch the same three places. @@ -307,7 +319,7 @@ The user-facing help portal at documents the ID The portal is **a separate repository** — MkDocs-built from (Markdown sources under `docs-help/docs/`, nav in `docs-help/mkdocs.yml`). Doc fixes go there as PRs, not in this repo. Blog posts (`docs-blogs/docs/YYYY/MM/DD/*.md`) need three coordinated updates per post: the markdown source, `docs/blogs.json` (CI normally regenerates from the last 5 dated `.md` files via `.github/folders.sh`, but a manual edit lights the home page up immediately), and `docs-blogs/mkdocs.yml` nav (year heading + per-post entry). The `docs/` tree is otherwise CI-generated — see the repo's own `CLAUDE.md` and don't hand-edit it for routine changes; `ci skip` in a commit message *suppresses* the regeneration job. -The companion sample repos under `dirigiblelabs/*` are also separate. For end-to-end tests that clone such repos (`SampleProjectRepositoryIT` subclasses), the sample-side PR has to merge first; the dirigible-side IT will otherwise fail CI when it clones an empty `master`. +The companion sample repos under `dirigiblelabs/*` are also separate. For end-to-end tests that clone such repos (`SampleProjectRepositoryIT` subclasses), the merge order follows the dependency direction, both ways: a dirigible-side IT that expects new sample content needs the sample-side PR merged first (else it clones an empty `master`), and a sample-side change that uses new platform APIs needs the platform PR merged first — the ITs clone the sample repo's HEAD, so merging the sample early breaks **every** PR's CI and master until the platform lands (this happened with the typed-handler interfaces: sample PR merged a day before platform PR [#6010](https://github.com/eclipse-dirigible/dirigible/pull/6010), and `JavaEntityDecoratorsSampleProjectIT` failed across the board until #6010 merged). Re-running a failed PR check does NOT pick up a newer master — re-runs build the original merge snapshot; use `gh pr update-branch` (or push) to get a fresh merge. Treat `modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java` (the enum) and `Configuration.java` (the allow-list) as the source of truth for env-var names and defaults, not the portal — the env-vars page lags the code by months. Specifically, the in-repo OAuth flow is Spring's `spring.security.oauth2.client.registration.github.*` activated by the `github` profile and configured via `DIRIGIBLE_GITHUB_CLIENT_ID` / `_CLIENT_SECRET` / `_SCOPE` (`build/application/src/main/resources/application-github.properties`), not the generic `DIRIGIBLE_OAUTH_*` entries some doc pages still describe. Current servlet mappings are rooted at `/services/...` and `/public/...` per `BaseEndpoint`; any `/services/v4/...` URL in a doc snippet is legacy. 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 4d43f56dce9..0e9aa6f72cd 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; + } + } } /** diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md new file mode 100644 index 00000000000..fc9f6961a55 --- /dev/null +++ b/components/engine/engine-intent/CLAUDE.md @@ -0,0 +1,298 @@ +# engine-intent + +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.*`. + +## Editor-first architecture - **read this first** + +**The intent is an authoring artifact, not a runtime artifact.** The platform draws this line +sharply and it dictates the whole design: authoring artifacts (`.edm`, `.model`, `.form`, +`.report`) get **editors in the workspace plus an explicit Generate step**; only runtime artifacts +(`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, ...) are reconciled from the registry by +synchronizers. The `.edm` is the precedent: it has NO synchronizer - and neither does the intent. + +The developer workflow (the only flow): + +``` +1. create a project in your workspace +2. create app.intent (any *.intent) at the project root, authored by hand / Claude / structured panel +3. double-click → the Intent Editor opens: editable YAML text left, live read-only diagram right + (ER + one flowchart per process + forms/reports/roles/seeds summaries), validation issues inline +4. click Generate → the six generators write the derived model files NEXT TO app.intent, + IN YOUR WORKSPACE PROJECT (nothing is published): + .edm + .model ← entities + relations + UI metadata + .bpmn ← processes +

.form ← forms + .report ← reports + .roles ← permissions + .csvim + .csv ← seed data +5. (follow-up: Generate also chains the model-to-code templates via .gen descriptors) +6. publish → the registry receives intent + models + generated code together; + the per-artefact synchronizers bring the runtime live as for any other project +``` + +**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 real-world codbex application keeps its model files. The folders layer as: `app.intent` +(authored) + root model files (intent-owned, scrubbed by this engine's Generate) → `gen/` +(template-owned, wiped by the template engine) → `custom/` (hand-written escape hatch, touched by +nobody). + +The published `app.intent` in the registry is an inert source file (exactly like a published +`.edm`). Consequence to document, not fix: an intent-only project deployed headlessly (git → +registry) does not self-materialize - the generated models are committed/published artifacts, +produced in the workspace. + +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. + +### 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 in the Intent Editor; Generate refreshes the model files at the project root, and the template engine turns them into the app under `gen/`; the editor's diagram pane renders the intent for a quick read-only visual at every step. + +### 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 + +- **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 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.) +- **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/. + +### 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. +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. + +4. **An IntentSynchronizer + JPA artefact for the intent itself.** The first incarnation treated the intent as a runtime artifact: a `BaseSynchronizer` parsed published `.intent` files into a `DIRIGIBLE_INTENTS` table and regenerated the models **in the registry** during reconciliation. Two unfixable consequences: generation happened only after publish (invisible in the Projects view, unusable by the modelers and by "Generate from EDM", which all work on the workspace), and the UI could only be a registry-reading perspective instead of an editor. The platform's own line is: **authoring artifacts (`.edm`, `.form`, `.report`, `.intent`) get editors + an explicit Generate in the workspace; only runtime artifacts get synchronizers** - note the `.edm` has no synchronizer either. The synchronizer, the JPA artefact, and the Mermaid perspective were all removed in favour of `editor-intent` + the parse/generate endpoints. Do not reintroduce them; this separation is hard-won low-code-platform experience. + +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 + +``` +components/engine/engine-intent/ # backend: parser + generators + REST services +├── pom.xml # depends on core-base, core-database, core-repository, ide-workspace +├── about.html +├── CLAUDE.md # this file +└── src/main/java/org/eclipse/dirigible/components/intent/ + ├── model/ # POJOs for the intent document (plain-Gson-mapped after YAML → Map → JSON round-trip) + │ ├── IntentModel.java # root: name / entities / processes / forms / reports / permissions / seeds + │ ├── EntityIntent.java / FieldIntent.java / RelationIntent.java + │ ├── ProcessIntent.java / StepIntent.java + │ └── FormIntent.java / ReportIntent.java / PermissionIntent.java / SeedIntent.java + ├── parser/ + │ ├── IntentParser.java # YAML → Map (SnakeYAML SafeConstructor) → JSON → IntentModel (plain Gson) + structural validation + │ └── IntentValidationException.java # collects every structural issue in one shot + ├── generator/ + │ ├── IntentTargetGenerator.java # SPI - one per slice (entities, processes, forms, ...) + │ ├── IntentGenerationContext.java # parsed model + target project paths; writeModelFile() is the only write surface + │ ├── IntentNaming.java # shared naming: baseName, upperSnake, tableName (_) + │ ├── IntentGenerationService.java # runs the SPI beans in @Order, scrubs stale output, returns written/scrubbed + │ ├── edm/EdmIntentGenerator.java # @Order(200); .edm (XML) + .model (JSON) + │ ├── bpmn/BpmnIntentGenerator.java # @Order(300); .bpmn per process + │ ├── form/FormIntentGenerator.java # @Order(400); .form per form + │ ├── report/ReportIntentGenerator.java # @Order(500); .report per report + │ ├── permission/PermissionIntentGenerator.java # @Order(600); .roles + │ └── csvim/CsvimIntentGenerator.java # @Order(700); .csvim + .csv per seed + └── endpoint/IntentEndpoint.java # POST /services/ide/intent/parse + /generate (workspace-targeted) +``` + +The editor lives in one sibling UI module: + +- `components/ui/editor-intent` - registered for content type `application/yaml+intent` (the `intent` extension is mapped in `ContentTypeHelper`) via the `platform-editors` extension point, like every other specialized editor. Split layout: an embedded Monaco editor (YAML highlighting, theme-synced to the IDE via `ThemingHub`, loaded from the `/webjars/monaco-editor/...` webjar that `editor-monaco` already ships) left (Save, ctrl+s, dirty tracking via `LayoutHub`; `$scope.text` stays the single source the parse/save/diagram code reads, kept in sync from Monaco's change event), live diagram right (an mxGraph ER-style diagram for the entities + one top-down mxGraph flowchart per process + forms/reports/roles/seeds summaries), validation issues as an inline strip fed by `POST /parse` on a 600ms debounce. The Generate button calls `POST /generate` and refreshes the project tree via the `projects.tree.refresh` dialog-hub topic (the same mechanism the form-builder uses). The diagram uses **mxGraph 4.2.2** (the same engine the EDM / schema / mapping modelers use), depended on as `dirigible-components-resources-mxgraph` and loaded from `/services/web/resources/mxgraph/4.2.2/src/js/mxClient.js` with `mxBasePath` set in the editor HTML. + +### Intent Editor diagram = mxGraph, fixed-colour palette (read before touching `editor-intent/js/editor.js`) + +The diagram pane was Mermaid through mid-2026 and had two unfixable-in-practice theming defects (invisible connector lines in dark mode; a "Syntax error in text" bomb on every light↔dark switch). It was **rewritten on mxGraph** - the rendering engine Dirigible already trusts in the EDM/schema/mapping modelers - which removed both defects by construction. Do not reintroduce Mermaid. + +**How it renders.** `render()` tears down any live graphs, then builds one read-only `mxGraph` per section into a freshly created container appended to `#intent-diagrams`: +- `renderEntities()` - one blue HTML-label card per entity (name over its field list, PK marked); a solid edge for a required relation, dashed for an optional one. Laid out left-to-right with `mxHierarchicalLayout(graph, mxConstants.DIRECTION_WEST)` - the same layout the processes use; the organic layout was tried first but collapsed every card onto the origin because they all start at (0,0). +- `renderProcess()` - slate start/end ellipses, blue user-task / green service-task rounded rects, amber decision rhombus; decision steps emit a conditioned edge (label = `if`) to `then` and a default edge to `else` (falling back to the next step), exactly like `BpmnIntentGenerator`. Laid out top-down with `mxHierarchicalLayout(graph, mxConstants.DIRECTION_NORTH)`. +Each graph is `setEnabled(false)` (read-only, no selection/editing), HTML labels on, and sized to its content (`sizeToContent` → the pane scrolls). The forms/reports/roles/seeds summaries stay as Angular-bound HTML below the diagrams. + +**Why theming is now a non-issue (the whole point of the rewrite).** The cells use **fixed brand colours** (`COLOR` map at the top of the controller: entity blue `#3584e4`, service green `#26a269`, decision amber `#e9a319`, terminal slate `#708090`, edge mid-gray `#7a8896`, white labels) chosen to read on **both** the light and dark IDE themes, painted on a transparent canvas whose background is the theme's. So the diagram looks identical in either theme and there is **no recolour-on-theme-switch step to break** - the controller deliberately has no `ThemingHub` listener for the diagram. This matches the schema/entity modelers' approach (solid-coloured shapes, white text) and is what the user asked for. If you ever need a colour to follow the theme foreground instead, resolve the CSS var to a concrete `rgb()` in JS first (mxGraph writes `stroke`/`fill` as raw SVG presentation attributes via `mxSvgCanvas2D`, and `var(...)` in an SVG attribute is not reliably resolved) - do **not** put `var(--…)` into an mxGraph style string for stroke/fill and assume it tracks the theme. + +**Stale-jar trap (still the most likely reason "nothing changed").** `editor-intent` is static web resources **bundled into the `build/application` fat jar**. `mvn install -pl components/ui/editor-intent` updates the `.m2` artifact but NOT an already-built `dirigible-application-*-executable.jar`. After editing, `mvn -P quick-build install -pl components/ui/editor-intent` **and** `mvn -P quick-build package -pl build/application`, then restart. Verify the running jar is current: `curl -s -u admin:admin http://localhost:8080/services/web/editor-intent/js/editor.js | grep -c mxGraph` (expect a non-zero count; `mermaid` should be gone). `IntentEditorLoadsIT` does not have this problem (failsafe repackages independently), which is why a green IT never proves the user's jar is current. + +`IntentEditorLoadsIT` **clones [`dirigiblelabs/sample-intent-model`](https://github.com/dirigiblelabs/sample-intent-model)** (the library intent sample - same clone-a-real-repo pattern as the `SampleProjectRepositoryIT` subclasses; it replaced the old local `IntentEditorIT` fixture so the published sample stays the single source of truth), opens its `app.intent`, then asserts the mxGraph diagram renders (`.intent-diagram svg` is visible) **and** that the parsed `Book` entity's label appears inside `.intent-diagram` - so it fails on an empty or broken diagram, unlike the old "any `` exists" check that a Mermaid error bomb satisfied. It does not separately exercise a theme switch because the fixed-colour palette renders identically in both themes. Editing the sample's entities/process means the sample repo must change too (the IT clones its HEAD). `IntentEngineIT` stays self-contained (inline YAML) for fast, network-free coverage. + +### General build/serve gotcha (applies to all UI modules) + +Web resources under `components/ui/*` and `components/.../resources` are bundled into the `build/application` fat jar. After editing them: `mvn -P quick-build install -pl ` then `mvn -P quick-build package -pl build/application`, and restart the running jar. A locally running instance does NOT hot-reload changed module resources. Integration tests repackage `tests-integrations` and are unaffected - so a passing IT never proves the user's running jar is current. + +Six concrete generators currently live in-module: + +- [`EdmIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java) writes `.edm` (XML) plus `.model` (JSON twin) from the entities + relations declared in the intent. Each entity is fleshed out with EDM editor defaults (icons, menu keys, layout type, perspective metadata, widget types) derived from the entity / field names so the produced model is a complete, openable EDM document. Conventions mirrored from the real codbex apps (which are written by the Dirigible authors themselves, so they *are* the canonical Dirigible conventions - `codbex-invoices`, `codbex-uoms` are good references): **property `name`s are PascalCase** (`id`->`Id`, `loanedOn`->`LoanedOn`, FK `member`->`Member`) via `IntentNaming.pascalCase`, while the physical column **`dataName` stays UPPER_SNAKE** and intent-prefixed (`ORDERS_COUNTRY`) - authoring stays lower camelCase, only the generated model names are PascalCased; every property carries **`auditType="NONE"`**, and a required field/FK also carries **`isRequiredProperty="true"`** (the generated REST controller's required-value validation keys on it, not on `dataNullable`). **Every to-one FK property carries the full relationship metadata the generation reads** (the `.model` has no separate relations array, so it must live on the property): `relationshipType` (`COMPOSITION` for `composition: true`, else `ASSOCIATION`), `relationshipCardinality` (`1_n` composition, `n_1` manyToOne association, `1_1` oneToOne association), `relationshipName` = the FK constraint name `_` (e.g. `Loan_Member` - used as the DB FK constraint name in the schema template), `relationshipEntityName` = target entity, `relationshipEntityPerspectiveName` = target's resolved perspective, `relationshipEntityPerspectiveLabel="Entities"`. **The dropdown's data-service URL (`api//Service.ts`) and the create-detail dialog are built from `relationshipEntityName` + `relationshipEntityPerspectiveName`** - omitting them generated `api/undefined/undefinedController.ts` and a dead dropdown. A `composition: true` relation additionally makes the owner DEPENDENT/MANAGE_DETAILS, inheriting its transitively-resolved parent perspective; every other to-one stays a PRIMARY association DROPDOWN. Dropdown key/value and `referencedProperty` come from the target entity's actual PK and `name`-like fields (PascalCased); the `.model` JSON carries `entities`/`perspectives`/`navigations` (no `relations` key - relations are XML-only, interleaved with their owning ``). The `.edm` also carries an **`mxGraphModel`** diagram with a `style="entity"` vertex per entity (carrying an `` value), a child vertex per property (carrying a `` value), and an edge per FK relation - the EDM editor renders the canvas *exclusively* by decoding `mxGraphModel`, so without it the editor opens empty. Entities are placed in a fixed grid for deterministic output. +- [`BpmnIntentGenerator`](src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java) writes one `.bpmn` per process. Minimal Flowable-flavoured BPMN 2.0 - one start event, one end event, the declared steps, and the sequence flows that connect them. Decisions emit an exclusiveGateway with a conditioned outgoing flow to `args.then` and a default flow to `args.else` (falling back to the next step in the chain when omitted). The `trigger` block is consumed by the EDM generator (adds a `ProcessId` field to an `onCreate` target) - the BPMN itself still has a plain none-start event; the runtime auto-start (listener/handler) is the pending `gen/events` template step. Emits the **`bpmndi:BPMNDiagram`** block (plus the `omgdc`/`omgdi` namespaces): the Flowable/Oryx modeler renders the canvas *only* from the diagram interchange - a process with no `BPMNShape`s opens empty. Nodes are laid out left-to-right along the linear chain at a fixed lane; edges connect source-right to target-left. The layout is deterministic (byte-stable across regenerations); the modeler re-routes on first manual edit. +- [`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 in the **codbex `.report` shape** (see `codbex-invoices/*.report`): `name` / `alias` (the source entity, the base-table alias) / `table` (intent-prefixed physical table via `IntentNaming.tableName`) / `columns` / a fully-materialised SQL **`query`** / `conditions` / `security`. The report is rooted at `source`; each dimension/measure resolves to a physical column - a plain field (`dueOn`) -> a source column; a **`relation.field` path (`member.name`) -> an `INNER JOIN` to the related entity** plus a column on it (this is how a report shows a parent's columns); a **bare to-one relation (`member`) -> an `INNER JOIN` showing the target's label (`name`-like) field, not the raw FK id** (so "group by member" displays the member's name; use `member.id` for the id); a measure `count(*)`/`sum(total)`/`avg`/`min`/`max` -> an aggregate column (dimensions then become the `GROUP BY`). `filter` becomes the `WHERE`, with intent field names rewritten to qualified physical columns (`dueOn <= CURRENT_DATE` -> `Loan.LOAN_DUE_ON <= CURRENT_DATE`); operators / literals / `CURRENT_DATE` pass through. `security` is `{generateDefaultRoles, roleRead: .Report.ReadOnly}`. Column physical names + the base table mirror `EdmIntentGenerator` so the report never drifts from the model. (The earlier version left `query` empty and used a non-codbex shape - reports did not run.) **Caveat:** the base-table alias is the entity name; a reserved-word entity name (`Order`) yields an unquoted reserved alias - keep entity names non-reserved, as the codbex apps do. +- [`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` | `.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 + +- **No artefact type, no JPA table, no synchronizer.** The intent never touches the database; the `.intent` file in the workspace is the single source of truth. +- **Content type `application/yaml+intent`** mapped from the `intent` extension in `ContentTypeHelper` (`modules/commons/commons-helpers`) - this is what routes a double-click to the Intent Editor. +- **Module registered in:** `components/pom.xml` (both `engine/engine-intent` and `ui/editor-intent`), `components/group/group-engines/pom.xml` (engine), `components/group/group-ide/pom.xml` (editor). + +## Services flow + +1. `POST /services/ide/intent/parse` (body: raw YAML) - `IntentParser.parse` → `IntentModel` JSON, or `422 {"issues": [...]}` with every structural problem at once. The editor calls this on a debounce to refresh the diagram and the validation strip; nothing is persisted. +2. `POST /services/ide/intent/generate?workspace=&project=&path=` - resolves the current user's workspace project via `WorkspaceService` (so it is inherently user-scoped), reads the intent file, runs every registered `IntentTargetGenerator` (failures per generator are logged and isolated), then scrubs stale intent-owned files. Returns `{"written": [...], "scrubbed": [...]}`. +3. **Stale-output scrub.** Model-layer files at the project root that the pass did not re-emit are deleted. The extension filter keeps the scrub away from the `.intent` file itself, code files, and subfolders (`gen/`, `custom/` - only direct child resources are considered). Removing a process / form / seed from the intent removes its model file on the next Generate. +4. **Generation is idempotent and diff-stable** - identical input produces byte-identical output, and byte-identical content is not rewritten. + +## 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: integer, 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, else: end } + - 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] } + +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`, `int`, `long`, `decimal`, `double`, `boolean`, `date`, `timestamp`, `uuid`. Generators map them to JDBC + EDM types. **Primary keys must be an integer type (`integer`/`int`/`long`)** - the codbex model convention is integer auto-increment identifiers, and a non-integer auto-increment column is invalid SQL (a `uuid`/`VARCHAR` PK produced `AUTO_INCREMENT` on a `VARCHAR(36)` column, which H2 rejects); the parser enforces this and the EDM generator only emits `dataAutoIncrement` for integer columns. `uuid` remains valid for non-PK fields (maps to `VARCHAR(36)`). Relation kinds: `oneToMany`, `manyToOne`, `oneToOne`, `manyToMany`. Step kinds: `userTask`, `serviceTask`, `decision`, `script`, `end`. + +Semantics worth knowing: + +- **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching codbex (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores `oneToMany`/`manyToMany` since the FK lives on the child). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. +- **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: { onCreate: }` is partly wired.** The parser validates the target is a declared entity, and the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity (`TriggerSupport.onCreateTargetEntities`). The **runtime** half - start the process when the entity is created - is NOT done yet: it needs the DAO to publish a create event and a `gen/events` listener+handler to call `Process.start` and write `ProcessId` back. The design (decided): a new **intent-driven language template** generates the glue-code under `gen/events` (a sibling gen subfolder, so it survives per-model `gen/` regeneration), Java-first (`@Listener` + `MessageHandler` + `Process.start`), and the Java DAO template gains the event publish (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', …)`) the TS DAO already has. `onSchedule` is still unmodelled. Do not promise the runtime trigger to users until the `gen/events` template 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` `table` 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. +- **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 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. +- **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 round-trip authoring editor.** Mermaid is read-only visualisation (it lives in the right pane of the Intent Editor). Authoring is the YAML text pane + prompt + structured panel. +- **Diagram editors render only from their layout blocks - always emit them.** The EDM editor decodes `mxGraphModel`, the BPMN modeler decodes `bpmndi:BPMNDiagram`; both open to an empty canvas if the block is missing (this shipped once - the generators wrongly assumed the editors auto-lay-out on open). Any generator whose target opens in a visual modeler must emit a deterministic layout, not just the logical model. +- **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 + +- `.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. +- Chain the model-to-code templates from the editor's Generate button so the developer sees the full app, not just the model files. Today the user opens the generated `.edm`/`.form` and clicks Generate there. **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 the editor's Generate chain straight into `GenerateService.generateFromModel(...)` - the exact mechanism the form-builder's Regenerate button already uses (see `editor-form-builder/js/editor.js`). +- 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 as a third pane (or dialog) of the Intent Editor. 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. +- Mark intent-generated model files as not-for-hand-editing in the IDE (decoration in the Projects view or a banner in the modelers). +- `/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. +- Finish wiring the `trigger` block (model side done - `onCreate` validated + adds `ProcessId`): build the `gen/events` intent-driven language template that emits the listener+handler (`@Listener` -> `Process.start` -> write `ProcessId`) and add the create-event publish to the Java DAO template; then `onSchedule` -> timer start event / `.job`. + +**Done:** + +- Editor-first architecture: `editor-intent` (split text + live diagram, Save, Generate) registered for `*.intent`; `POST /parse` + workspace-targeted `POST /generate`; no synchronizer, no JPA artefact, nothing in the registry until publish. +- Structural validation on parse: duplicate names, dangling relation / form / report / seed targets, unknown field / relation / step kinds, decision then/else target checks, multi-PK and empty-seed checks. Surfaced via `IntentValidationException` with the complete list of issues in one error message; the editor shows them inline. +- 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). +- Integration test [`IntentEngineIT`](../../../tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java): parse (full model + all-issues-at-once validation), generate into the workspace project with content assertions on every artefact, the stale-output scrub, and 422 on invalid intents. HTTP-only, no Selenide, no synchronization cycles - the whole class runs in under a minute. +- Stale-output scrub on regeneration (the project-root ownership contract above). +- Reliable `forceProcessSynchronizers` (bounded wait instead of silent skip) in `core-initializers` - kept even though intent no longer uses it; it fixes a whole class of IT flakes. +- Diagram pane rendered with mxGraph 4.2.2 (replacing Mermaid) using a fixed brand-colour palette that reads on both themes - this resolved the dark-mode invisible-lines and theme-switch "Syntax error" defects by construction. +- Source pane is an embedded Monaco editor with YAML highlighting (replacing the plain textarea), theme-synced to the IDE via `ThemingHub`, reusing the `/webjars/monaco-editor/...` webjar `editor-monaco` already ships. +- Integer-only primary keys: the parser rejects a non-integer PK (`integer`/`int`/`long`) and the EDM generator only emits `dataAutoIncrement` for integer columns - fixes the `AUTO_INCREMENT` on a `VARCHAR(36)` uuid PK that H2 rejected during full-stack table creation. +- Opt-in composition: `composition: true` (not "first required to-one") marks a master-detail; `required` alone is just a NOT NULL FK association. +- EDM output aligned to the codbex/Dirigible conventions: PascalCase property names (columns stay UPPER_SNAKE), `auditType="NONE"` and `isRequiredProperty` on every property, and **full relationship metadata on every FK property** (`relationshipType`/`Cardinality`/`Name` + `relationshipEntityName`/`relationshipEntityPerspectiveName`/`relationshipEntityPerspectiveLabel`) - the last two are what the generated UI uses to build the FK dropdown's data-service URL; without them the dropdown loaded `api/undefined/undefinedController.ts` and failed. +- REST template fix (`template-application-rest-v2`): the max-length validation is now guarded by `$property.dataLength`, so a CLOB/text property (no length) no longer leaks a literal `${property.dataLength}` into the generated controller. The Java REST template already had the guard. +- Reports rewritten to the codbex `.report` shape with a materialised SQL `query` (was empty), `relation.field` -> `INNER JOIN`, `filter` -> qualified `WHERE`, and default-role `security`. Covered by `IntentEngineIT` (aggregate + join/filter reports). +- Cross-artefact PascalCase: the `.form` control `model`/`id` bind to the PascalCase EDM property name; a bare to-one relation report dimension auto-joins and shows the target's `name`-like field instead of the raw FK id. +- Trigger model side: `trigger: { onCreate: }` is validated (target must be a declared entity) and adds a `ProcessId` back-reference property to that entity via `TriggerSupport`. The runtime auto-start (the `gen/events` listener/handler template + Java DAO event publish) is the remaining step. + +**Cross-artefact field naming:** the `.form` control `model` (and control `id`) bind to the entity property, so they use `IntentNaming.pascalCase` to match the EDM property names (`loanedOn` -> `LoanedOn`). The `.report` references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing. diff --git a/components/engine/engine-intent/about.html b/components/engine/engine-intent/about.html new file mode 100644 index 00000000000..b89d656fe61 --- /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 00000000000..87b6c21fe6b --- /dev/null +++ b/components/engine/engine-intent/pom.xml @@ -0,0 +1,40 @@ + + + + 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 + + + org.eclipse.dirigible + dirigible-components-ide-workspace + + + + + ../../../licensing-header.txt + ../../../ + + 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 00000000000..839f54714c1 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/endpoint/IntentEndpoint.java @@ -0,0 +1,131 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.util.Map; + +import jakarta.annotation.security.RolesAllowed; + +import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint; +import org.eclipse.dirigible.components.ide.workspace.domain.File; +import org.eclipse.dirigible.components.ide.workspace.domain.Project; +import org.eclipse.dirigible.components.ide.workspace.domain.Workspace; +import org.eclipse.dirigible.components.ide.workspace.service.WorkspaceService; +import org.eclipse.dirigible.components.intent.generator.IntentGenerationService; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.eclipse.dirigible.components.intent.parser.IntentValidationException; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +/** + * REST surface for the intent editor. The intent is an authoring artifact (like the {@code .edm}); + * both operations work on the current user's workspace, never on the registry: + *
    + *
  • {@code POST /services/ide/intent/parse} - body is the raw intent YAML; returns the parsed + * {@link IntentModel} (drives the editor's live diagram), or {@code 422} with the full list of + * structural issues.
  • + *
  • {@code POST /services/ide/intent/generate} - generates every derived model file into the + * given workspace project (next to the intent file) and scrubs stale output; returns the written + * and scrubbed file names.
  • + *
+ */ +@RestController +@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_IDE + "intent") +@RolesAllowed({"ADMINISTRATOR", "DEVELOPER"}) +public class IntentEndpoint { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntentEndpoint.class); + + private final IntentGenerationService generationService; + private final WorkspaceService workspaceService; + + public IntentEndpoint(IntentGenerationService generationService, WorkspaceService workspaceService) { + this.generationService = generationService; + this.workspaceService = workspaceService; + } + + /** + * Parse and validate an intent document. The editor calls this on every (debounced) change to + * refresh the diagram and surface validation issues without saving. + * + * @param yaml the raw intent YAML + * @return the parsed model, or {@code 422} with {@code {"issues": [...]}} when validation fails + */ + @PostMapping(value = "/parse", consumes = MediaType.TEXT_PLAIN_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity parse(@RequestBody(required = false) String yaml) { + try { + IntentModel model = IntentParser.parse(yaml); + return ResponseEntity.ok(model); + } catch (IntentValidationException e) { + return ResponseEntity.unprocessableEntity() + .body(Map.of("issues", e.getIssues())); + } + } + + /** + * Generate the derived model files for the given intent file into its workspace project. + * + * @param workspace the workspace name, e.g. {@code workspace} + * @param project the project name + * @param path the intent file path within the project, e.g. {@code app.intent} + * @return the written and scrubbed file names, or {@code 422} with the validation issues + */ + @PostMapping(value = "/generate", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity generate(@RequestParam("workspace") String workspace, @RequestParam("project") String project, + @RequestParam("path") String path) { + Workspace workspaceObject = workspaceService.getWorkspace(workspace); + if (!workspaceObject.exists()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Workspace [" + workspace + "] does not exist"); + } + Project projectObject = workspaceObject.getProject(project); + if (!projectObject.exists()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Project [" + project + "] does not exist"); + } + File intentFile = projectObject.getFile(path); + if (!intentFile.exists()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Intent file [" + path + "] does not exist in project [" + project + "]"); + } + String yaml = new String(intentFile.getContent(), StandardCharsets.UTF_8); + try { + IntentGenerationService.GenerationResult result = + generationService.generate(yaml, projectObject.getPath(), project, baseName(path)); + return ResponseEntity.ok( + Map.of("workspace", workspace, "project", project, "written", result.written(), "scrubbed", result.scrubbed())); + } catch (IntentValidationException e) { + return ResponseEntity.unprocessableEntity() + .body(Map.of("issues", e.getIssues())); + } catch (RuntimeException e) { + LOGGER.error("Intent generation failed for [{}/{}/{}]", workspace, project, path, e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Generation failed: " + e.getMessage(), e); + } + } + + /** + * The file's base name without path and the {@code .intent} extension - the fallback identity when + * the YAML declares no {@code name:}. + */ + private static String baseName(String path) { + String fileName = path.substring(path.lastIndexOf('/') + 1); + int dot = fileName.lastIndexOf('.'); + return dot > 0 ? fileName.substring(0, dot) : fileName; + } +} 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 00000000000..3dfbd7bb6eb --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationContext.java @@ -0,0 +1,120 @@ +/* + * 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.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.model.IntentModel; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IResource; + +/** + * Per-generation call context handed to every {@link IntentTargetGenerator}. Carries the parsed + * intent model, the target project paths inside the Dirigible repository, and the single write + * entry point {@link #writeModelFile(String, String)}. + * + *

+ * Generation targets the developer's workspace project ({@code /users/// + * }) - the intent is an authoring artifact like the {@code .edm}: the developer edits it + * in its editor, clicks Generate, reviews the derived model files in the project, and publishes + * everything together. Model files are written directly at the project root (next to the + * {@code .intent} file) - the location every downstream consumer is proven to handle. NOT under + * {@code gen/}: the model-to-code templates wipe that folder wholesale on every regeneration. + * + *

+ * All writes go through {@link #writeModelFile(String, String)}, which records the emitted file + * names so {@link IntentGenerationService} can scrub files that a previous generation wrote but the + * current one no longer produces. + */ +public final class IntentGenerationContext { + + /** Repository path of the target project root, e.g. {@code /users/admin/workspace/my-library}. */ + private final String projectRoot; + + /** The target project name. */ + private final String projectName; + + /** Base-name fallback when the intent YAML declares no {@code name:} - the file's base name. */ + private final String fallbackName; + + private final IntentModel model; + private final IRepository repository; + + /** Bare file names written under {@link #projectRoot} during this generation pass. */ + private final Set writtenFileNames = new LinkedHashSet<>(); + + IntentGenerationContext(IntentModel model, String projectRoot, String projectName, String fallbackName, IRepository repository) { + this.model = model; + this.projectRoot = projectRoot; + this.projectName = projectName; + this.fallbackName = fallbackName; + this.repository = repository; + } + + /** + * 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. + * + * @param fileName bare file name including extension, e.g. {@code library.edm} + * @param content the full file content + */ + 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); + } + 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; + } + + /** + * Base-name fallback for single-file outputs when the YAML omits {@code name:}. + * + * @return the intent file's base name, never null + */ + public String getFallbackName() { + return fallbackName; + } + + public IntentModel getModel() { + return model; + } + + public String getProjectRoot() { + return projectRoot; + } + + public IRepository getRepository() { + return repository; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java new file mode 100644 index 00000000000..cd2ac8b5124 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentGenerationService.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Runs the generation pass for one intent document against a target project: 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 generation; the extension filter keeps the scrub away from the + * {@code .intent} file itself, code files, and the {@code gen/} / {@code custom/} subfolders (only + * direct child resources are considered). Removing a process / form / report / seed from the intent + * therefore removes its model file on the next Generate instead of leaving a stale artefact around. + */ +@Component +public class IntentGenerationService { + + private static final Logger LOGGER = LoggerFactory.getLogger(IntentGenerationService.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 generation 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; + + public IntentGenerationService(List generators, IRepository repository) { + this.generators = generators; + this.repository = repository; + } + + /** + * Outcome of a generation pass: the files emitted and the stale files scrubbed. + * + * @param written bare names of the model files this pass produced + * @param scrubbed bare names of previously generated files removed because the intent no longer + * declares their slice + */ + public record GenerationResult(List written, List scrubbed) { + } + + /** + * Parse the given intent YAML and (re)generate every model artefact in the target project, + * scrubbing stale intent-owned files afterwards. + * + * @param yaml the raw {@code .intent} document + * @param projectRoot repository path of the target project root, e.g. + * {@code /users/admin/workspace/my-library} + * @param projectName the target project name + * @param fallbackName base name used for single-file outputs when the YAML omits {@code name:} - + * conventionally the intent file's base name + * @return the files written and scrubbed + * @throws org.eclipse.dirigible.components.intent.parser.IntentValidationException if the document + * has structural problems + */ + public GenerationResult generate(String yaml, String projectRoot, String projectName, String fallbackName) { + IntentModel model = IntentParser.parse(yaml); + IntentGenerationContext context = new IntentGenerationContext(model, projectRoot, projectName, fallbackName, repository); + LOGGER.info("Generating model files for intent [{}] under [{}] via {} generator(s)", IntentNaming.baseName(context), projectRoot, + generators.size()); + for (IntentTargetGenerator generator : generators) { + try { + generator.generate(context); + } catch (RuntimeException e) { + LOGGER.error("Intent generator [{}] failed for project [{}]", generator.name(), projectName, e); + } + } + List scrubbed = scrubStaleModelFiles(projectRoot, context.getWrittenFileNames()); + return new GenerationResult(new ArrayList<>(context.getWrittenFileNames()), scrubbed); + } + + /** + * Remove intent-owned model files at the project root that are not part of the current output set. + */ + private List scrubStaleModelFiles(String projectRoot, Set keep) { + List scrubbed = new ArrayList<>(); + ICollection project = repository.getCollection(projectRoot); + if (!project.exists()) { + return scrubbed; + } + for (String fileName : project.getResourcesNames()) { + if (keep.contains(fileName) || !isIntentOwned(fileName)) { + continue; + } + try { + repository.removeResource(projectRoot + "/" + fileName); + scrubbed.add(fileName); + LOGGER.info("Scrubbed stale intent output [{}/{}]", projectRoot, fileName); + } catch (RuntimeException e) { + LOGGER.error("Failed to scrub stale intent output [{}/{}]", projectRoot, fileName, e); + } + } + return scrubbed; + } + + private static boolean isIntentOwned(String fileName) { + int dot = fileName.lastIndexOf('.'); + return dot >= 0 && INTENT_OWNED_EXTENSIONS.contains(fileName.substring(dot)); + } +} 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 00000000000..ffa67901b97 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentNaming.java @@ -0,0 +1,95 @@ +/* + * 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 name derived from the file name + * ({@code app}) is a poor identity. Falls back to the intent file's base 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 fallbackName = context.getFallbackName(); + if (fallbackName != null && !fallbackName.isBlank()) { + return fallbackName; + } + 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); + } + + /** + * Capitalize the first letter to make an UpperCamelCase (PascalCase) name, preserving the rest - + * the codbex/Dirigible EDM convention for property names ({@code id} -> {@code Id}, + * {@code loanedOn} -> {@code LoanedOn}). Authoring stays lower camelCase; only the generated model + * property names are PascalCased (column {@code dataName}s stay UPPER_SNAKE). + * + * @param name the identifier to convert (may be null) + * @return the PascalCase form, empty for null/empty input + */ + public static String pascalCase(String name) { + if (name == null || name.isEmpty()) { + return ""; + } + return Character.toUpperCase(name.charAt(0)) + name.substring(1); + } + + /** + * 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/IntentTargetGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/IntentTargetGenerator.java new file mode 100644 index 00000000000..b27d910fe08 --- /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 IntentGenerationService}. 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 through {@link IntentGenerationContext#writeModelFile(String, String)}. + * + * @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/generator/TriggerSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/TriggerSupport.java new file mode 100644 index 00000000000..6744e1cf832 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/TriggerSupport.java @@ -0,0 +1,49 @@ +/* + * 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.LinkedHashSet; +import java.util.Set; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.ProcessIntent; + +/** + * Shared reading of a process's {@code trigger} block. Today only {@code onCreate} is modelled: + * {@code trigger: { onCreate: }} on a process means "start this process when an instance of + * {@code } is created". The model-layer effect is that the entity gains a {@code ProcessId} + * back-reference field; the runtime wiring (the {@code .listener} + handler under {@code gen/events} + * and the DAO event publish) is generated by the language templates as a later step. + */ +public final class TriggerSupport { + + private TriggerSupport() {} + + /** The entity named by a process's {@code trigger: { onCreate: }}, or null when absent. */ + public static String onCreateEntity(ProcessIntent process) { + Object value = process.getTrigger() + .get("onCreate"); + return value == null ? null : value.toString(); + } + + /** Entity names that some process starts on create - they get the {@code ProcessId} back-reference. */ + public static Set onCreateTargetEntities(IntentModel model) { + Set targets = new LinkedHashSet<>(); + for (ProcessIntent process : model.getProcesses()) { + String entity = onCreateEntity(process); + if (entity != null && !entity.isBlank()) { + targets.add(entity); + } + } + return targets; + } +} 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 00000000000..737aa99fa37 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/bpmn/BpmnIntentGenerator.java @@ -0,0 +1,498 @@ +/* + * 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.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.IntentNaming; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 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} -> + * {@code }
  • + *
  • {@code serviceTask} / {@code script} -> + * {@code } with the {@code handler} extension + * element pointing at {@code args.call}
  • + *
  • {@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 })
  • + *
+ * + *

+ * The {@code bpmndi:BPMNDiagram} block IS emitted (see {@link #appendBpmnDiagram}): the + * Flowable/Oryx modeler renders the canvas only from the diagram interchange, so a process with no + * shapes opens empty. Nodes are laid out left-to-right on a fixed lane for deterministic, + * byte-stable output; the modeler re-routes on first manual edit. + * + *

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

+ * 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 +@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; + } + Set seenFiles = new HashSet<>(); + for (ProcessIntent process : model.getProcesses()) { + if (process.getName() == null || process.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed process in intent [{}]", IntentNaming.baseName(context)); + continue; + } + String fileName = process.getName() + ".bpmn"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate process [{}] in intent [{}] - keeping the first occurrence", process.getName(), + IntentNaming.baseName(context)); + continue; + } + if (!process.getTrigger() + .isEmpty()) { + LOGGER.info( + "Process [{}] declares a trigger; the BPMN keeps a none-start event - auto-start (listener/handler under gen/events) is generated separately, so for now start it explicitly", + process.getName()); + } + context.writeModelFile(fileName, render(process)); + } + } + + private static String render(ProcessIntent process) { + List steps = process.getSteps(); + List effectiveSteps = buildEffectiveStepIds(steps); + List flows = buildSequenceFlows(steps, effectiveSteps); + String processId = process.getName(); + StringBuilder sb = new StringBuilder(4096); + 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"); + writeSequenceFlows(sb, flows); + sb.append(" \n"); + appendBpmnDiagram(sb, processId, effectiveSteps, flows, steps); + 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"); + } + + /** A resolved sequence flow: stable id, source/target element ids, and an optional condition. */ + private record SequenceFlow(String id, String source, String target, String condition) { + } + + /** + * Build the sequence flows. The default is a linear chain through {@link #buildEffectiveStepIds}. + * 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 List buildSequenceFlows(List steps, List effectiveIds) { + List flows = new ArrayList<>(); + for (int i = 0; i < effectiveIds.size() - 1; i++) { + String source = effectiveIds.get(i); + String target = effectiveIds.get(i + 1); + String flowId; + 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; + } + flows.add(new SequenceFlow(flowId, source, target, null)); + } + 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; + } + flows.add(new SequenceFlow("flow_" + step.getName() + "_then", step.getName(), effectiveTarget(thenTarget, steps), condition)); + } + return flows; + } + + private static void writeSequenceFlows(StringBuilder sb, List flows) { + for (SequenceFlow flow : flows) { + sb.append(" "); + if (flow.condition() != null) { + sb.append("\n \n "); + } + sb.append("\n"); + } + } + + /** + * 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 step; + } + } + return null; + } + + // ----- BPMN diagram interchange (bpmndi) --------------------------------------------------- + + private static final int LANE_Y = 140; + private static final int NODE_SPACING = 160; + private static final int FIRST_NODE_CENTER_X = 100; + + /** + * Append the {@code bpmndi:BPMNDiagram} block. The Flowable/Oryx modeler renders the canvas + * only from this diagram interchange (a process with no {@code BPMNShape}s opens empty), so + * it is mandatory. Nodes are laid out left-to-right along the linear chain at a fixed lane; edges + * connect the right edge of the source to the left edge of the target. The layout is deterministic + * so re-generation is byte-stable; the modeler re-routes on first manual edit. + */ + private static void appendBpmnDiagram(StringBuilder sb, String processId, List effectiveIds, List flows, + List steps) { + Map bounds = new java.util.LinkedHashMap<>(); + for (int i = 0; i < effectiveIds.size(); i++) { + String id = effectiveIds.get(i); + if (bounds.containsKey(id)) { + continue; + } + int[] size = nodeSize(id, steps); + int centerX = FIRST_NODE_CENTER_X + i * NODE_SPACING; + int x = centerX - size[0] / 2; + int y = LANE_Y - size[1] / 2; + bounds.put(id, new int[] {x, y, size[0], size[1]}); + } + + sb.append(" \n"); + sb.append(" \n"); + for (Map.Entry entry : bounds.entrySet()) { + int[] b = entry.getValue(); + sb.append(" \n \n \n"); + } + for (SequenceFlow flow : flows) { + int[] source = bounds.get(flow.source()); + int[] target = bounds.get(flow.target()); + if (source == null || target == null) { + continue; + } + int x1 = source[0] + source[2]; + int y1 = source[1] + source[3] / 2; + int x2 = target[0]; + int y2 = target[1] + target[3] / 2; + sb.append(" \n \n \n \n"); + } + sb.append(" \n"); + sb.append(" \n"); + } + + /** Width/height of a node's shape by its element id / step kind. */ + private static int[] nodeSize(String id, List steps) { + if (START_ID.equals(id)) { + return new int[] {30, 30}; + } + if (END_ID.equals(id)) { + return new int[] {28, 28}; + } + StepIntent step = stepByName(id, steps); + String kind = step == null ? "userTask" : step.getKind(); + if ("decision".equalsIgnoreCase(kind)) { + return new int[] {40, 40}; + } + return new int[] {100, 80}; + } + + private static StepIntent stepByName(String name, List steps) { + for (StepIntent step : steps) { + if (name.equals(step.getName())) { + return step; + } + } + return null; + } + + 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(); + } +} 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 00000000000..66a84c4bd52 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/csvim/CsvimIntentGenerator.java @@ -0,0 +1,200 @@ +/* + * 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.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits two files per {@link SeedIntent}: + *

    + *
  • {@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} + * 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); + Set seenFiles = new HashSet<>(); + for (SeedIntent seed : model.getSeeds()) { + if (seed.getName() == null || seed.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed seed in intent [{}]", IntentNaming.baseName(context)); + continue; + } + EntityIntent entity = entitiesByName.get(seed.getEntity()); + if (entity == null) { + LOGGER.warn("Seed [{}] references unknown entity [{}] - skipping", seed.getName(), seed.getEntity()); + continue; + } + String fileName = fileNameOnly(seed.getName()); + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate seed [{}] in intent [{}] - keeping the first occurrence", seed.getName(), + IntentNaming.baseName(context)); + continue; + } + context.writeModelFile(fileName + ".csv", renderCsv(orderedFieldsOf(entity), entity, seed)); + context.writeModelFile(fileName + ".csvim", renderCsvim(context, seed, entity, fileName)); + } + } + + 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 = IntentNaming.upperSnake(entity.getName()); + for (int i = 0; i < fields.size(); i++) { + if (i > 0) { + sb.append(FIELD_DELIM); + } + sb.append(entityDataName) + .append('_') + .append(IntentNaming.upperSnake(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(IntentGenerationContext context, SeedIntent seed, EntityIntent entity, String fileName) { + Map entry = new LinkedHashMap<>(); + entry.put("table", IntentNaming.tableName(context, entity.getName())); + entry.put("schema", seed.getSchema() == null || seed.getSchema() + .isBlank() ? DEFAULT_SCHEMA : seed.getSchema()); + entry.put("file", "/" + context.getProjectName() + "/" + 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); + } +} 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 00000000000..f0d24f3d7c6 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -0,0 +1,796 @@ +/* + * 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.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +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.generator.TriggerSupport; +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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 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. + * + *

+ * Conventions mirrored from real EDM documents (see + * {@code tests-integrations/.../DependsOnScenariosTestProject/sales-order.edm}): + *

    + *
  • {@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 manyToOne}/{@code oneToOne} relation marked {@code composition: true} 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 (and the FK is NOT NULL). Every other relation + * is an association: a plain DROPDOWN property whose FK is NOT NULL when {@code required}, + * and the entity stays {@code PRIMARY} with its own perspective. Composition is opt-in (matching + * codbex, where it is an explicit {@code relationshipType="COMPOSITION"}); {@code required} alone + * only means the FK column is NOT NULL.
  • + *
  • 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 }.
  • + *
+ * An {@code mxGraphModel} diagram block IS emitted with a deterministic grid layout: the EDM editor + * renders the canvas exclusively by decoding {@code mxGraphModel}, so without it the editor opens + * empty. See {@link #appendMxGraphModel}. + * + *

+ * 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; + } + String baseName = IntentNaming.baseName(context); + EdmDocument document = buildDocument(model, baseName); + context.writeModelFile(baseName + ".model", JsonHelper.toJson(document.modelJson)); + context.writeModelFile(baseName + ".edm", renderEdmXml(document)); + } + + /** 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(); + Map byName = indexEntities(entities); + Map compositionParents = computeCompositionParents(entities); + Set triggerTargets = TriggerSupport.onCreateTargetEntities(model); + + EdmDocument document = new EdmDocument(); + List> entityList = new ArrayList<>(); + List> perspectiveList = new ArrayList<>(); + String tablePrefix = IntentNaming.upperSnake(intentName); + int perspectiveOrder = 1; + + for (EntityIntent entity : entities) { + String name = entity.getName(); + if (name == null || name.isBlank()) { + LOGGER.warn("Skipping unnamed entity in intent"); + continue; + } + 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()) { + if (field.getName() == null || field.getName() + .isBlank()) { + continue; + } + properties.add(propertyMap(name, field)); + } + // An entity a process starts on create carries a ProcessId back-reference (the runtime + // listener/handler writes the started process-instance id here). See TriggerSupport. + if (triggerTargets.contains(name)) { + properties.add(processIdProperty(name)); + } + List> relations = new ArrayList<>(); + boolean compositionAssigned = false; + for (RelationIntent relation : entity.getRelations()) { + if (!"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { + continue; + } + if (relation.getName() == null || relation.getTo() == null) { + continue; + } + boolean composition = !compositionAssigned && relation.isComposition(); + compositionAssigned |= composition; + EntityIntent target = byName.get(relation.getTo()); + String targetPerspective = resolvePerspective(relation.getTo(), compositionParents); + properties.add(relationProperty(name, relation, target, composition, targetPerspective)); + 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); + 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); + } + } + return index; + } + + /** + * Map each entity to its composition parent: the target of its first {@code composition: true} + * {@code manyToOne} / {@code oneToOne} relation. Entities present as keys are DEPENDENT; their + * perspective is the parent's, resolved transitively by {@link #resolvePerspective}. + */ + private static Map computeCompositionParents(List entities) { + Map parents = new LinkedHashMap<>(); + for (EntityIntent entity : entities) { + if (entity.getName() == null) { + continue; + } + for (RelationIntent relation : entity.getRelations()) { + boolean toOne = "manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()); + if (toOne && relation.isComposition() && relation.getTo() != null) { + parents.put(entity.getName(), relation.getTo()); + break; + } + } + } + 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, String perspective, + String tablePrefix, int order) { + String dataName = tablePrefix + "_" + IntentNaming.upperSnake(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", perspective); + entity.put("perspectiveLabel", perspective); + 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 = IntentNaming.upperSnake(entityName) + "_" + IntentNaming.upperSnake(field.getName()); + String dataType = mapDataType(field.getType()); + Map p = new LinkedHashMap<>(); + // Property names are PascalCase (Dirigible/codbex convention); the physical column dataName + // stays UPPER_SNAKE, derived from the authored field name above. + p.put("name", IntentNaming.pascalCase(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"); + } else if (field.isRequired()) { + // The generated REST controller's required-value validation keys on isRequiredProperty + // (not dataNullable); set it so a required field is actually validated. PKs are excluded + // (auto-generated), matching codbex. + p.put("isRequiredProperty", "true"); + } + // Auto-increment is a DB identity/sequence - valid only on integer columns (a VARCHAR/uuid + // AUTO_INCREMENT is rejected by the database). The parser already enforces integer primary keys; + // this keeps the generator correct on its own. + if (field.isPrimaryKey() && field.isGenerated() && ("INTEGER".equals(dataType) || "BIGINT".equals(dataType))) { + p.put("dataAutoIncrement", "true"); + } + Integer length = fieldLength(field); + 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("auditType", "NONE"); + p.put("widgetType", widgetForType(dataType)); + p.put("widgetSize", ""); + p.put("widgetLength", length == null ? "20" : length.toString()); + p.put("widgetIsMajor", "true"); + return p; + } + + /** + * The {@code ProcessId} back-reference property added to an entity that a process starts on + * create. A plain VARCHAR holding the started process-instance id; the runtime trigger handler + * writes it. Not a major widget - it is system-managed, not user input. + */ + private static Map processIdProperty(String entityName) { + Map p = new LinkedHashMap<>(); + p.put("name", "ProcessId"); + p.put("description", "Process instance started for this record"); + p.put("tooltip", ""); + p.put("dataName", IntentNaming.upperSnake(entityName) + "_" + IntentNaming.upperSnake("ProcessId")); + p.put("dataType", "VARCHAR"); + p.put("dataNullable", "true"); + p.put("dataLength", "100"); + p.put("auditType", "NONE"); + p.put("widgetType", "TEXTBOX"); + p.put("widgetSize", ""); + p.put("widgetLength", "100"); + p.put("widgetIsMajor", "false"); + return p; + } + + /** + * FK property added to the owning entity for a {@code manyToOne}/{@code oneToOne} relation. Renders + * 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 composition: true} to-one) carries the {@code relationship*} attributes that make the EDM + * editor treat the owner as a detail of the target - every other relation stays a plain association + * (NOT NULL when {@code required}), mirroring how the EDM editor writes multi-FK entities. + */ + private static Map relationProperty(String ownerEntity, RelationIntent relation, EntityIntent target, + boolean composition, String targetPerspective) { + String column = IntentNaming.upperSnake(ownerEntity) + "_" + IntentNaming.upperSnake(relation.getName()); + FieldIntent targetPk = primaryKeyOf(target); + String fkType = targetPk == null ? "INTEGER" : mapDataType(targetPk.getType()); + boolean oneToOne = "oneToOne".equals(relation.getKind()); + Map p = new LinkedHashMap<>(); + p.put("name", IntentNaming.pascalCase(relation.getName())); + p.put("description", relation.getDescription() == null ? "" : relation.getDescription()); + p.put("tooltip", ""); + p.put("dataName", column); + p.put("dataType", fkType); + // A composition FK is always NOT NULL (the detail cannot exist without its parent), even if + // `required` was not also set; otherwise nullability follows `required`. + boolean notNull = relation.isRequired() || composition; + p.put("dataNullable", notNull ? "false" : "true"); + if (notNull) { + p.put("isRequiredProperty", "true"); + } + if ("VARCHAR".equals(fkType) && targetPk != null) { + Integer length = fieldLength(targetPk); + if (length != null && length > 0) { + p.put("dataLength", length.toString()); + } + } + p.put("auditType", "NONE"); + // Relationship metadata the generation reads (codbex .model convention): composition vs + // association + cardinality (composition 1_n; association n_1 for manyToOne, 1_1 for oneToOne); + // relationshipName is the FK constraint name _; relationshipEntityName and + // relationshipEntityPerspectiveName drive the dropdown's data-service URL + // (api//Service.ts) and the create-detail dialog. + p.put("relationshipType", composition ? "COMPOSITION" : "ASSOCIATION"); + p.put("relationshipCardinality", composition ? "1_n" : (oneToOne ? "1_1" : "n_1")); + p.put("relationshipName", ownerEntity + "_" + relation.getTo()); + p.put("relationshipEntityName", relation.getTo()); + p.put("relationshipEntityPerspectiveName", targetPerspective); + p.put("relationshipEntityPerspectiveLabel", "Entities"); + p.put("widgetType", "DROPDOWN"); + p.put("widgetSize", ""); + p.put("widgetLength", "20"); + p.put("widgetIsMajor", "true"); + p.put("widgetDropDownKey", keyFieldName(target)); + p.put("widgetDropDownValue", labelFieldName(target)); + return p; + } + + /** + * 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, EntityIntent target, + Map compositionParents) { + Map link = new LinkedHashMap<>(); + String linkName = ownerEntity + "_" + IntentNaming.pascalCase(relation.getName()); + link.put("name", linkName); + link.put("type", "relation"); + link.put("entity", ownerEntity); + link.put("relationName", linkName); + link.put("relationshipEntityPerspectiveName", resolvePerspective(relation.getTo(), compositionParents)); + link.put("relationshipEntityPerspectiveLabel", "Entities"); + link.put("property", IntentNaming.pascalCase(relation.getName())); + link.put("referenced", relation.getTo()); + 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 (PascalCase); {@code Id} as a last resort. + */ + private static String keyFieldName(EntityIntent target) { + FieldIntent pk = primaryKeyOf(target); + return pk == null ? "Id" : IntentNaming.pascalCase(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 IntentNaming.pascalCase(field.getName()); + } + } + for (FieldIntent field : target.getFields()) { + if (field.getName() != null && "VARCHAR".equals(mapDataType(field.getType())) && !field.isPrimaryKey()) { + return IntentNaming.pascalCase(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"; + } + 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; + } + } + + /** + * Render the EDM XML shape: entities with their relations interleaved, the perspectives and + * navigations blocks, then the {@code mxGraphModel} diagram. The mxGraphModel is mandatory, not + * optional: the EDM editor renders the canvas exclusively by decoding {@code mxGraphModel} + * (see {@code editor-entity/js/editor.js} - {@code codec.decode(... getElementsByTagName( + * 'mxGraphModel')[0] ...)}); without it the editor opens to an empty canvas. The diagram is laid + * out deterministically in a grid so re-generation is byte-stable. + */ + @SuppressWarnings("unchecked") + 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(8192); + sb.append("\n"); + sb.append(" \n"); + for (Map entity : entities) { + sb.append(" > properties = (List>) entity.getOrDefault("properties", List.of()); + 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"); + 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"); + 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"); + appendMxGraphModel(sb, document, entities); + sb.append("\n"); + return sb.toString(); + } + + /** Entity box width and row heights for the deterministic grid layout. */ + private static final int CELL_WIDTH = 200; + private static final int TITLE_HEIGHT = 28; + private static final int ROW_HEIGHT = 26; + private static final int GRID_COLUMNS = 3; + private static final int COLUMN_GAP = 80; + private static final int ROW_GAP = 40; + private static final int GRID_ORIGIN = 20; + + /** + * Append the {@code mxGraphModel} the EDM editor decodes to render the canvas: one + * {@code style="entity"} vertex per entity carrying an {@code } value, a child vertex per + * property carrying a {@code } value, and one edge per foreign-key relation linking the + * owner's FK property to the target entity's primary-key property. Entities are placed in a fixed + * grid so the output is deterministic across regenerations. + */ + private static void appendMxGraphModel(StringBuilder sb, EdmDocument document, List> entities) { + // Pre-compute cell ids and the per-entity primary-key property cell id (edge targets). + Map entityCellId = new HashMap<>(); + Map> propertyCellId = new HashMap<>(); + Map pkCellIdByEntity = new HashMap<>(); + for (Map entity : entities) { + String name = (String) entity.get("name"); + String entCell = "ent_" + sanitizeId(name); + entityCellId.put(name, entCell); + Map props = new LinkedHashMap<>(); + List> properties = propertiesOf(entity); + for (Map property : properties) { + String propName = (String) property.get("name"); + String propCell = entCell + "_p_" + sanitizeId(propName); + props.put(propName, propCell); + if ("true".equals(property.get("dataPrimaryKey")) && !pkCellIdByEntity.containsKey(name)) { + pkCellIdByEntity.put(name, propCell); + } + } + propertyCellId.put(name, props); + } + + sb.append(" \n \n"); + sb.append(" \n"); + sb.append(" \n"); + + int[] columnY = new int[GRID_COLUMNS]; + for (int i = 0; i < GRID_COLUMNS; i++) { + columnY[i] = GRID_ORIGIN; + } + int index = 0; + for (Map entity : entities) { + String name = (String) entity.get("name"); + List> properties = propertiesOf(entity); + int column = index % GRID_COLUMNS; + int x = GRID_ORIGIN + column * (CELL_WIDTH + COLUMN_GAP); + int y = columnY[column]; + int height = TITLE_HEIGHT + ROW_HEIGHT * Math.max(properties.size(), 1); + columnY[column] = y + height + ROW_GAP; + index++; + + sb.append(" \n"); + appendEntityValue(sb, entity); + sb.append(" \n"); + sb.append(" \n"); + + int propIndex = 0; + for (Map property : properties) { + sb.append(" \n"); + appendPropertyValue(sb, property); + sb.append(" \n"); + sb.append(" \n"); + propIndex++; + } + } + + // Edges: owner FK property -> target entity primary-key property. + for (Map.Entry>> entry : document.relationsByEntity.entrySet()) { + String owner = entry.getKey(); + for (Map relation : entry.getValue()) { + String property = (String) relation.get("property"); + String referenced = (String) relation.get("referenced"); + String source = propertyCellId.getOrDefault(owner, Map.of()) + .get(property); + String target = pkCellIdByEntity.get(referenced); + if (source == null || target == null) { + continue; + } + sb.append(" \n"); + } + } + + sb.append(" \n \n"); + } + + @SuppressWarnings("unchecked") + private static List> propertiesOf(Map entity) { + return (List>) entity.getOrDefault("properties", List.of()); + } + + /** + * The {@code } cell value. {@code type="Entity"} is the constant cell-kind marker the + * editor keys on; the PRIMARY/DEPENDENT distinction is carried in {@code entityType} (omitted for + * PRIMARY), matching the editor's own serializer. + */ + private static void appendEntityValue(StringBuilder sb, Map entity) { + sb.append(" \n"); + } + + /** The {@code } cell value - the property's attributes verbatim. */ + private static void appendPropertyValue(StringBuilder sb, Map property) { + sb.append(" attr : property.entrySet()) { + appendAttribute(sb, attr.getKey(), attr.getValue()); + } + sb.append(" as=\"value\"/>\n"); + } + + /** mxGraph cell ids must be attribute-safe and stable; keep only word characters. */ + private static String sanitizeId(String raw) { + return raw == null ? "" : raw.replaceAll("[^A-Za-z0-9_]", "_"); + } + + private static void appendAttribute(StringBuilder sb, String key, Object value) { + sb.append(' ') + .append(key) + .append("=\"") + .append(escapeXml(value == null ? "" : value.toString())) + .append("\""); + } + + 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); + 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(); + } + +} 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 00000000000..bba195dfd2e --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/form/FormIntentGenerator.java @@ -0,0 +1,357 @@ +/* + * 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.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.FormIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 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. + * + *

+ * 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); + Set seenFiles = new HashSet<>(); + for (FormIntent form : model.getForms()) { + if (form.getName() == null || form.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed form in intent [{}]", IntentNaming.baseName(context)); + continue; + } + String fileName = form.getName() + ".form"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate form [{}] in intent [{}] - keeping the first occurrence", form.getName(), + IntentNaming.baseName(context)); + continue; + } + EntityIntent boundEntity = form.getForEntity() == null ? null : entitiesByName.get(form.getForEntity()); + Map document = buildForm(form, boundEntity); + context.writeModelFile(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); + // The control binds to the entity property, whose name the EDM generator emits in PascalCase + // (loanedOn -> LoanedOn); the `model` binding (and the control id) must match it so the form + // reads/writes the right field. Use the same IntentNaming.pascalCase the EDM uses. + String property = IntentNaming.pascalCase(fieldName); + Map map = new LinkedHashMap<>(); + map.put("controlId", control.controlId); + map.put("groupId", "fb-controls"); + map.put("id", property + "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", property); + 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(); + } + + /** + * 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/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 00000000000..6db9a8213b5 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/permission/PermissionIntentGenerator.java @@ -0,0 +1,93 @@ +/* + * 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.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.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.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * 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 + * 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; + } + context.writeModelFile(IntentNaming.baseName(context) + ".roles", 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); + } +} 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 00000000000..7a7a06071f7 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGenerator.java @@ -0,0 +1,576 @@ +/* + * 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.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 com.google.gson.Gson; +import com.google.gson.GsonBuilder; +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.components.intent.model.ReportIntent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +/** + * Emits one {@code .report} per {@link ReportIntent}, in the JSON shape the report editor + * and the report runtime consume (the codbex convention - see {@code codbex-invoices/*.report}): an + * outer record with {@code name} / {@code alias} (base-table alias) / {@code table} (physical base + * table) / {@code columns} / a fully materialised SQL {@code query} / {@code conditions} / + * {@code security}. + * + *

+ * The report is rooted at {@link ReportIntent#getSource()}. Each dimension and measure resolves to + * a physical column: + *

    + *
  • a plain field ({@code dueOn}) -> a column on the source table;
  • + *
  • a {@code relation.field} path ({@code member.name}) -> an {@code INNER JOIN} to the + * related entity plus a column on it - this is how a report shows columns from a parent/related + * entity;
  • + *
  • a bare to-one relation name ({@code book}) -> the foreign-key column on the source;
  • + *
  • a measure {@code count(*)} / {@code sum(total)} / {@code avg(price)} / + * {@code min}/{@code max} -> an aggregate column (and the dimensions become the + * {@code GROUP BY}).
  • + *
+ * {@link ReportIntent#getFilter()} becomes the {@code WHERE} predicate, with the intent's field + * names rewritten to their qualified physical columns (so {@code dueOn <= CURRENT_DATE} -> + * {@code Loan.LOAN_DUE_ON <= CURRENT_DATE}); non-field tokens (operators, {@code CURRENT_DATE}, + * literals) pass through untouched. + * + *

+ * Column physical names and the base table mirror what {@code EdmIntentGenerator} emits + * ({@code _} columns, {@code _} table) so the report can never drift + * from the model. Generation is idempotent - identical input yields byte-identical output. + */ +@Component +@Order(500) +public class ReportIntentGenerator implements IntentTargetGenerator { + + private static final Logger LOGGER = LoggerFactory.getLogger(ReportIntentGenerator.class); + + /** + * Pretty-printed JSON with HTML-escaping OFF so the SQL {@code query} keeps literal {@code =} / + * {@code >} / {@code <} operators (the platform's {@code JsonHelper} escapes them to + * {@code \\u003d} etc.; valid JSON, but unreadable and unlike the codbex {@code .report} files). + * Maps only - no {@code @Expose} concern. + */ + private static final Gson REPORT_JSON = new GsonBuilder().setPrettyPrinting() + .disableHtmlEscaping() + .create(); + + /** {@code aggregate(field)} pattern - aggregate in group 1, 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"); + private static final Pattern DOTTED_REF = Pattern.compile("\\b([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\b"); + private static final Pattern SIMPLE_CONDITION = Pattern.compile("^\\s*(\\S+)\\s*(<=|>=|<>|!=|=|<|>)\\s*(.+?)\\s*$"); + + @Override + public String name() { + return "report"; + } + + @Override + public void generate(IntentGenerationContext context) { + IntentModel model = context.getModel(); + if (model.getReports() + .isEmpty()) { + return; + } + Set seenFiles = new HashSet<>(); + for (ReportIntent report : model.getReports()) { + if (report.getName() == null || report.getName() + .isBlank()) { + LOGGER.warn("Skipping unnamed report in intent [{}]", IntentNaming.baseName(context)); + continue; + } + String fileName = report.getName() + ".report"; + if (!seenFiles.add(fileName)) { + LOGGER.warn("Duplicate report [{}] in intent [{}] - keeping the first occurrence", report.getName(), + IntentNaming.baseName(context)); + continue; + } + context.writeModelFile(fileName, REPORT_JSON.toJson(build(context, report))); + } + } + + private static Map build(IntentGenerationContext context, ReportIntent report) { + IntentModel model = context.getModel(); + EntityIntent source = entityByName(model, report.getSource()); + String baseAlias = report.getSource() == null ? report.getName() : report.getSource(); + String baseTable = report.getSource() == null ? "" : IntentNaming.tableName(context, report.getSource()); + + boolean aggregated = report.getMeasures() + .stream() + .anyMatch(m -> m != null && !m.isBlank()); + + Map joins = new LinkedHashMap<>(); + List> columns = new ArrayList<>(); + List selectParts = new ArrayList<>(); + List groupParts = new ArrayList<>(); + + for (String dimension : report.getDimensions()) { + if (dimension == null || dimension.isBlank()) { + continue; + } + ColumnRef ref = resolve(context, model, source, baseAlias, dimension.trim()); + registerJoin(joins, ref); + columns.add(column(ref.tableAlias, ref.displayAlias, ref.physicalColumn, ref.reportType, "NONE", aggregated)); + selectParts.add(ref.qualified() + " as \"" + ref.displayAlias + "\""); + if (aggregated) { + groupParts.add(ref.qualified()); + } + } + for (String measure : report.getMeasures()) { + if (measure == null || measure.isBlank()) { + continue; + } + addMeasure(context, model, source, baseAlias, measure.trim(), joins, columns, selectParts); + } + + String where = buildWhere(context, model, source, baseAlias, joins, report.getFilter()); + String query = buildQuery(baseTable, baseAlias, joins, selectParts, where, aggregated ? groupParts : List.of()); + + Map document = new LinkedHashMap<>(); + document.put("name", report.getName()); + document.put("alias", baseAlias); + document.put("table", baseTable); + document.put("tId", translationId(report.getName())); + document.put("label", humanize(report.getName())); + if (report.getDescription() != null && !report.getDescription() + .isBlank()) { + document.put("description", report.getDescription()); + } + document.put("columns", columns); + document.put("query", query); + document.put("conditions", conditions(context, model, source, baseAlias, report.getFilter())); + document.put("security", security(context, report.getName())); + return document; + } + + private static void addMeasure(IntentGenerationContext context, IntentModel model, EntityIntent source, String baseAlias, + String measure, Map joins, List> columns, List selectParts) { + 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)) { + if (field.isEmpty() || "*".equals(field)) { + String alias = aggregate.charAt(0) + aggregate.substring(1) + .toLowerCase(Locale.ROOT); + columns.add(column(baseAlias, alias, "*", "INTEGER", aggregate, false)); + selectParts.add(aggregate + "(*) as \"" + alias + "\""); + return; + } + ColumnRef ref = resolve(context, model, source, baseAlias, field); + registerJoin(joins, ref); + String alias = humanize(aggregate.toLowerCase(Locale.ROOT) + " " + leaf(field)); + String type = "COUNT".equals(aggregate) ? "INTEGER" + : ("MIN".equals(aggregate) || "MAX".equals(aggregate) ? ref.reportType : "DECIMAL"); + columns.add(column(ref.tableAlias, alias, ref.physicalColumn, type, aggregate, false)); + selectParts.add(aggregate + "(" + ref.qualified() + ") as \"" + alias + "\""); + return; + } + } + LOGGER.warn("Measure [{}] did not match the aggregate(field) convention - skipping", measure); + } + + /** + * Resolve a dimension/measure field reference to its physical column, joining when it crosses a + * relation. + */ + private static ColumnRef resolve(IntentGenerationContext context, IntentModel model, EntityIntent source, String baseAlias, + String reference) { + ColumnRef ref = new ColumnRef(); + int dot = reference.indexOf('.'); + if (dot > 0 && source != null) { + String relationName = reference.substring(0, dot); + String fieldName = reference.substring(dot + 1); + RelationIntent relation = relationByName(source, relationName); + if (relation != null && relation.getTo() != null) { + EntityIntent target = entityByName(model, relation.getTo()); + String targetAlias = relation.getTo(); + ref.tableAlias = targetAlias; + ref.physicalColumn = column(targetAlias, fieldName); + FieldIntent targetField = fieldByName(target, fieldName); + ref.reportType = reportType(targetField == null ? null : targetField.getType()); + ref.displayAlias = humanize(reference.replace('.', ' ')); + ref.join = join(context, source, relation, target, targetAlias, baseAlias); + return ref; + } + } + // A plain field on the source table. + FieldIntent field = source == null ? null : fieldByName(source, reference); + if (field != null) { + ref.tableAlias = baseAlias; + ref.physicalColumn = column(source.getName(), reference); + ref.reportType = reportType(field.getType()); + ref.displayAlias = humanize(reference); + return ref; + } + // A bare to-one relation (e.g. `member`): JOIN the related table and show its label (name) + // field rather than the raw FK id - "group by member" should display the member, not its id. + // Use `relation.field` to pick a specific column instead. + RelationIntent relation = source == null ? null : relationByName(source, reference); + if (relation != null && relation.getTo() != null + && ("manyToOne".equals(relation.getKind()) || "oneToOne".equals(relation.getKind()))) { + EntityIntent target = entityByName(model, relation.getTo()); + String targetAlias = relation.getTo(); + String labelField = labelFieldName(target); + FieldIntent labeled = fieldByName(target, labelField); + ref.tableAlias = targetAlias; + ref.physicalColumn = column(targetAlias, labelField); + ref.reportType = reportType(labeled == null ? null : labeled.getType()); + ref.displayAlias = humanize(reference); + ref.join = join(context, source, relation, target, targetAlias, baseAlias); + return ref; + } + // Best-effort: treat the reference as a raw column on the source. + ref.tableAlias = baseAlias; + ref.physicalColumn = column(source == null ? baseAlias : source.getName(), reference); + ref.reportType = "CHARACTER VARYING"; + ref.displayAlias = humanize(reference); + return ref; + } + + /** + * The related entity's label field (its {@code name}-like field; else first text field; else PK). + */ + private static String labelFieldName(EntityIntent target) { + if (target == null) { + return "id"; + } + 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 && !field.isPrimaryKey() && isTextType(field.getType())) { + return field.getName(); + } + } + FieldIntent pk = primaryKeyOf(target); + return pk == null ? "id" : pk.getName(); + } + + private static boolean isTextType(String type) { + if (type == null) { + return false; + } + String t = type.toLowerCase(Locale.ROOT); + return "string".equals(t) || "text".equals(t) || "uuid".equals(t); + } + + private static Join join(IntentGenerationContext context, EntityIntent source, RelationIntent relation, EntityIntent target, + String targetAlias, String baseAlias) { + FieldIntent targetPk = target == null ? null : primaryKeyOf(target); + String fkColumn = column(source.getName(), relation.getName()); + String pkColumn = column(targetAlias, targetPk == null ? "id" : targetPk.getName()); + return new Join(IntentNaming.tableName(context, targetAlias), targetAlias, + baseAlias + "." + fkColumn + " = " + targetAlias + "." + pkColumn); + } + + private static void registerJoin(Map joins, ColumnRef ref) { + if (ref.join != null) { + joins.putIfAbsent(ref.join.alias, ref.join); + } + } + + private static String buildQuery(String baseTable, String baseAlias, Map joins, List selectParts, String where, + List groupParts) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT ") + .append(selectParts.isEmpty() ? "*" : String.join(", ", selectParts)); + sql.append("\nFROM ") + .append(baseTable) + .append(" as ") + .append(baseAlias); + for (Join join : joins.values()) { + sql.append("\nINNER JOIN ") + .append(join.table) + .append(" as ") + .append(join.alias) + .append(" ON ") + .append(join.on); + } + if (where != null && !where.isBlank()) { + sql.append("\nWHERE ") + .append(where); + } + if (!groupParts.isEmpty()) { + sql.append("\nGROUP BY ") + .append(String.join(", ", groupParts)); + } + return sql.toString(); + } + + /** + * Rewrite the intent filter's field names to qualified physical columns; pass other tokens through. + */ + private static String buildWhere(IntentGenerationContext context, IntentModel model, EntityIntent source, String baseAlias, + Map joins, String filter) { + if (filter == null || filter.isBlank() || source == null) { + return filter == null ? null : filter.trim(); + } + Matcher matcher = DOTTED_REF.matcher(filter); + StringBuilder dotted = new StringBuilder(); + while (matcher.find()) { + RelationIntent relation = relationByName(source, matcher.group(1)); + if (relation != null && relation.getTo() != null) { + EntityIntent target = entityByName(model, relation.getTo()); + String targetAlias = relation.getTo(); + joins.putIfAbsent(targetAlias, join(context, source, relation, target, targetAlias, baseAlias)); + matcher.appendReplacement(dotted, Matcher.quoteReplacement(targetAlias + "." + column(targetAlias, matcher.group(2)))); + } else { + matcher.appendReplacement(dotted, Matcher.quoteReplacement(matcher.group())); + } + } + matcher.appendTail(dotted); + String where = dotted.toString(); + for (FieldIntent field : source.getFields()) { + if (field.getName() != null && !field.getName() + .isBlank()) { + where = where.replaceAll("\\b" + Pattern.quote(field.getName()) + "\\b", + Matcher.quoteReplacement(baseAlias + "." + column(source.getName(), field.getName()))); + } + } + return where.trim(); + } + + /** + * Best-effort structured condition for a single binary predicate (matches what the editor shows). + */ + private static List> conditions(IntentGenerationContext context, IntentModel model, EntityIntent source, + String baseAlias, String filter) { + List> conditions = new ArrayList<>(); + if (filter == null || filter.isBlank() || source == null) { + return conditions; + } + Matcher matcher = SIMPLE_CONDITION.matcher(filter.trim()); + if (matcher.matches()) { + Map condition = new LinkedHashMap<>(); + condition.put("left", qualifyToken(model, source, baseAlias, matcher.group(1))); + condition.put("operation", matcher.group(2)); + condition.put("right", qualifyToken(model, source, baseAlias, matcher.group(3))); + conditions.add(condition); + } + return conditions; + } + + /** + * Qualify a single filter token to a physical column when it names a field/relation.field; else + * leave it. + */ + private static String qualifyToken(IntentModel model, EntityIntent source, String baseAlias, String token) { + int dot = token.indexOf('.'); + if (dot > 0) { + RelationIntent relation = relationByName(source, token.substring(0, dot)); + if (relation != null && relation.getTo() != null) { + return relation.getTo() + "." + column(relation.getTo(), token.substring(dot + 1)); + } + return token; + } + return fieldByName(source, token) != null ? baseAlias + "." + column(source.getName(), token) : token; + } + + private static Map security(IntentGenerationContext context, String reportName) { + Map security = new LinkedHashMap<>(); + security.put("generateDefaultRoles", "true"); + String project = context.getProjectName(); + String prefix = project == null || project.isEmpty() ? IntentNaming.baseName(context) : project; + security.put("roleRead", prefix + ".Report." + reportName + "ReadOnly"); + return security; + } + + private static Map column(String tableAlias, String alias, String physicalColumn, String reportType, String aggregate, + boolean grouping) { + Map column = new LinkedHashMap<>(); + column.put("table", tableAlias); + column.put("alias", alias); + column.put("name", physicalColumn); + column.put("type", reportType); + column.put("aggregate", aggregate); + column.put("select", Boolean.TRUE); + column.put("grouping", grouping && "NONE".equals(aggregate)); + column.put("tId", translationId(alias)); + column.put("label", alias); + return column; + } + + private static String column(String entityName, String fieldName) { + return IntentNaming.upperSnake(entityName) + "_" + IntentNaming.upperSnake(fieldName); + } + + private static EntityIntent entityByName(IntentModel model, String name) { + if (name == null) { + return null; + } + for (EntityIntent entity : model.getEntities()) { + if (name.equals(entity.getName())) { + return entity; + } + } + return null; + } + + private static FieldIntent fieldByName(EntityIntent entity, String name) { + if (entity == null || name == null) { + return null; + } + for (FieldIntent field : entity.getFields()) { + if (name.equals(field.getName())) { + return field; + } + } + return null; + } + + private static RelationIntent relationByName(EntityIntent entity, String name) { + if (entity == null || name == null) { + return null; + } + for (RelationIntent relation : entity.getRelations()) { + if (name.equals(relation.getName())) { + return relation; + } + } + return null; + } + + private static FieldIntent primaryKeyOf(EntityIntent entity) { + for (FieldIntent field : entity.getFields()) { + if (field.isPrimaryKey() && field.getName() != null) { + return field; + } + } + return null; + } + + /** Logical intent field type to the SQL type the report editor records. */ + private static String reportType(String type) { + if (type == null) { + return "CHARACTER VARYING"; + } + 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 "CHARACTER LARGE OBJECT"; + case "uuid": + case "string": + default: + return "CHARACTER VARYING"; + } + } + + private static String leaf(String reference) { + int dot = reference.lastIndexOf('.'); + return dot < 0 ? reference : reference.substring(dot + 1); + } + + private static String translationId(String raw) { + if (raw == null) { + return ""; + } + return raw.replace(" ", "") + .replace("_", "") + .replace(".", "") + .replace(":", "") + .replace("*", "all"); + } + + /** camelCase / snake_case / dotted-path / spaced 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 == '.' || c == ' ') { + if (out.length() > 0 && out.charAt(out.length() - 1) != ' ') { + out.append(' '); + } + capitalizeNext = true; + continue; + } + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(raw.charAt(i - 1)) && out.length() > 0 + && out.charAt(out.length() - 1) != ' ') { + out.append(' '); + } + if (capitalizeNext) { + out.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + out.append(c); + } + } + return out.toString(); + } + + /** + * A resolved column reference: where it lives, its physical name + type, display alias, optional + * join. + */ + private static final class ColumnRef { + private String tableAlias; + private String physicalColumn; + private String reportType; + private String displayAlias; + private Join join; + + private String qualified() { + return tableAlias + "." + physicalColumn; + } + } + + /** An INNER JOIN to a related entity's table. */ + private static final class Join { + private final String table; + private final String alias; + private final String on; + + private Join(String table, String alias, String on) { + this.table = table; + this.alias = alias; + this.on = on; + } + } +} 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 00000000000..e7809d69704 --- /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 00000000000..f9dee5c9f13 --- /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 00000000000..4cdd0573373 --- /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 00000000000..750810476fd --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/IntentModel.java @@ -0,0 +1,109 @@ +/* + * 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<>(); + private List seeds = 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; + } + + 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/PermissionIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PermissionIntent.java new file mode 100644 index 00000000000..15ae5e286c4 --- /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 00000000000..1a528f9a4f5 --- /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 00000000000..51791db96e2 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/RelationIntent.java @@ -0,0 +1,72 @@ +/* + * 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 boolean composition; + 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 boolean isComposition() { + return composition; + } + + public void setComposition(boolean composition) { + this.composition = composition; + } + + 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 00000000000..5b6f3f0ee21 --- /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/SeedIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/SeedIntent.java new file mode 100644 index 00000000000..6b4529e12cf --- /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/model/StepIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/StepIntent.java new file mode 100644 index 00000000000..f9f59773aae --- /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 00000000000..8aa2f685c67 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -0,0 +1,332 @@ +/* + * 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.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +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.SeedIntent; +import org.eclipse.dirigible.components.intent.model.StepIntent; +import org.yaml.snakeyaml.LoaderOptions; +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 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 + * 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"); + /** + * Primary keys must be an integer type - the codbex model convention is integer identifiers + * (auto-increment), and a non-integer auto-increment column is invalid SQL on most databases. + */ + private static final Set INTEGER_PK_TYPES = Set.of("integer", "int", "long"); + 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() {} + + /** + * 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()) { + return new IntentModel(); + } + Yaml loader = new Yaml(new SafeConstructor(new LoaderOptions())); + Object tree = loader.load(yaml); + if (tree == null) { + return new IntentModel(); + } + String json = GSON.toJson(tree); + IntentModel model = GSON.fromJson(json, IntentModel.class); + 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, entityNames, issues); + validateForms(model, entityNames, issues); + validateReports(model, entityNames, issues); + validateSeeds(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++; + String type = field.getType() == null ? null + : field.getType() + .toLowerCase(Locale.ROOT); + if (!INTEGER_PK_TYPES.contains(type)) { + issues.add("entity [" + name + "] primary-key field [" + field.getName() + + "] must be an integer type (integer/int/long) - identifiers are integer by convention" + + (type == null ? "" : ", got [" + field.getType() + "]")); + } + } + } + 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.isComposition() && !"manyToOne".equals(relation.getKind()) && !"oneToOne".equals(relation.getKind())) { + issues.add("entity [" + entity.getName() + "] relation [" + relation.getName() + + "] is marked composition but only a manyToOne/oneToOne relation can be a composition"); + } + 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, Set entityNames, 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() + "]"); + } + Object onCreate = process.getTrigger() + .get("onCreate"); + if (onCreate != null && !entityNames.contains(onCreate.toString())) { + issues.add("process [" + process.getName() + "] trigger onCreate references unknown entity [" + onCreate + "]"); + } + 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() + "]"); + } + } + 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()) { + 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() + "]"); + } + } + } + + 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/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 00000000000..dc364d7ccd5 --- /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(); + } +} diff --git a/components/group/group-engines/pom.xml b/components/group/group-engines/pom.xml index b48c88239e9..1d8b337815d 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/group/group-ide/pom.xml b/components/group/group-ide/pom.xml index 4530e4e2e95..67f6e2f83ac 100644 --- a/components/group/group-ide/pom.xml +++ b/components/group/group-ide/pom.xml @@ -202,6 +202,10 @@ org.eclipse.dirigible dirigible-components-ui-view-messaging-browser + + org.eclipse.dirigible + dirigible-components-ui-editor-intent + org.eclipse.dirigible dirigible-components-ui-view-data-structures diff --git a/components/pom.xml b/components/pom.xml index 38ab87dc32d..3fa789c7e28 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 @@ -176,6 +177,7 @@ ui/view-monitoring-metrics ui/view-messaging-destinations ui/view-messaging-browser + ui/editor-intent ui/view-configurations ui/view-data-structures ui/view-extensions @@ -1018,6 +1020,11 @@ dirigible-components-ui-view-messaging-browser ${project.version} + + org.eclipse.dirigible + dirigible-components-ui-editor-intent + ${project.version} + org.eclipse.dirigible dirigible-components-ui-view-configurations diff --git a/components/template/template-application-rest-v2/src/main/resources/META-INF/dirigible/template-application-rest-v2/api/EntityController.ts.template b/components/template/template-application-rest-v2/src/main/resources/META-INF/dirigible/template-application-rest-v2/api/EntityController.ts.template index 3e1debf8a9f..0a60a184425 100644 --- a/components/template/template-application-rest-v2/src/main/resources/META-INF/dirigible/template-application-rest-v2/api/EntityController.ts.template +++ b/components/template/template-application-rest-v2/src/main/resources/META-INF/dirigible/template-application-rest-v2/api/EntityController.ts.template @@ -283,7 +283,7 @@ class ${name}Controller { throw new ValidationError(`The '${property.name}' property is required, provide a valid value`); } #end -#if($property.dataTypeTypescript == "string" && $property.dataTypeJava != "time") +#if($property.dataTypeTypescript == "string" && $property.dataLength && $property.dataTypeJava != "time") if (entity.${property.name}?.length > ${property.dataLength}) { throw new ValidationError(`The '${property.name}' exceeds the maximum length of [${property.dataLength}] characters`); } diff --git a/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/css/styles.css b/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/css/styles.css index 63783de8247..848932a1bdd 100644 --- a/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/css/styles.css +++ b/components/ui/editor-entity/src/main/resources/META-INF/dirigible/editor-entity/css/styles.css @@ -10,8 +10,14 @@ * SPDX-License-Identifier: EPL-2.0 */ :root { - --font_color: #303030; - --stroke_color: #303030; + /* + * Follow the BlimpKit theme foreground (set by the IDE theme switcher), NOT the OS + * prefers-color-scheme. Keying these off the OS made the entity/property text light while the + * switcher was on the light theme (light text on the light entity background = invisible); the + * editor body and icons already use --foreground, so the graph font/stroke now match. + */ + --font_color: var(--foreground, #303030); + --stroke_color: var(--foreground, #303030); --entity-header-color: #fff; --entity-color: #3584e4; --dependent-color: #2a6ab6; @@ -26,13 +32,6 @@ --extension-color: #046c7a; } -@media (prefers-color-scheme: dark) { - :root { - --font_color: #fafafa; - --stroke_color: #fafafa; - } -} - body { background-color: var(--background, #f7f7f7) !important; color: var(--foreground, #303030) !important; diff --git a/components/ui/editor-intent/about.html b/components/ui/editor-intent/about.html new file mode 100644 index 00000000000..bcb03d59e0f --- /dev/null +++ b/components/ui/editor-intent/about.html @@ -0,0 +1,29 @@ + + + + + +About + + +

About This Content

+ +

April 25, 2020

+

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/ui/editor-intent/pom.xml b/components/ui/editor-intent/pom.xml new file mode 100644 index 00000000000..ef1b98759ad --- /dev/null +++ b/components/ui/editor-intent/pom.xml @@ -0,0 +1,29 @@ + + 4.0.0 + + + org.eclipse.dirigible + dirigible-components-parent + 14.0.0-SNAPSHOT + ../../pom.xml + + + Components - UI - Intent - Editor + dirigible-components-ui-editor-intent + jar + + + + + org.eclipse.dirigible + dirigible-components-resources-mxgraph + + + + + ../../../licensing-header.txt + ../../../ + + + diff --git a/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/configs/intent-editor.js b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/configs/intent-editor.js new file mode 100644 index 00000000000..45251d54e86 --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/configs/intent-editor.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 editorData = { + id: 'intent-editor', + region: 'center', + label: 'Intent Editor', + path: '/services/web/editor-intent/editor.html', + contentTypes: ['application/yaml+intent'], +}; +if (typeof exports !== 'undefined') { + exports.getEditor = () => editorData; +} 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 new file mode 100644 index 00000000000..97a6fa9d4d9 --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/editor.html @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + 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 00000000000..fb81819c3a8 --- /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 00000000000..f3296e70712 --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/js/editor.js @@ -0,0 +1,460 @@ +/* + * 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, 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 = []; + let savedText = ''; + let parseTimer = null; + + // ----- Diagram palette ----------------------------------------------------- + // The diagram is drawn with mxGraph (the same engine the EDM and BPMN modelers use), so it inherits + // their robust rendering instead of Mermaid's brittle theming. Colours are fixed brand tones that + // read equally well on the light and dark IDE themes - solid fills with white labels on a + // transparent canvas, exactly like the schema/entity modelers - so the diagram looks identical in + // either theme and needs no recolour on a theme switch. Values mirror editor-entity/css/styles.css. + const COLOR = { + entity: '#3584e4', // blue - entities, user tasks + service: '#26a269', // green - service / script tasks + decision: '#e9a319', // amber - decision gateways + terminal: '#708090', // slate - start / end events + edge: '#7a8896', // mid-gray - relations and sequence flows, visible on both themes + label: '#ffffff' // white - on-shape text + }; + + // ----- 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(); + mountEditor(); + }); + }, (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; + }); + }); + }; + + // ----- Monaco source editor ------------------------------------------------ + // The left pane is a Monaco editor (the same engine as the platform's main code editor) with YAML + // highlighting; $scope.text stays the single source the parse / save / diagram code reads, kept in + // sync from Monaco's change event. The theme follows the IDE via ThemingHub, mirroring editor-monaco. + const themingHub = new ThemingHub(); + let monacoEditor = null; + + const monacoThemeFor = (theme) => { + if (!theme) theme = themingHub.getSavedTheme(); + if (theme && theme.type === 'light') return 'vs-light'; + const classic = theme && typeof theme.id === 'string' && theme.id.startsWith('classic'); + if (theme && theme.type === 'dark') return classic ? 'classic-dark' : 'blimpkit-dark'; + const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + if (!prefersDark) return 'vs-light'; + return classic ? 'classic-dark' : 'blimpkit-dark'; + }; + + // The platform's dark editor themes - editor-monaco defines these too; redefining is idempotent. + const defineMonacoThemes = (monaco) => { + monaco.editor.defineTheme('blimpkit-dark', { + base: 'vs-dark', inherit: true, rules: [{ background: '1d1d1d' }], + colors: { 'editor.background': '#1d1d1d', 'minimap.background': '#1d1d1d', 'editorGutter.background': '#1d1d1d' } + }); + monaco.editor.defineTheme('classic-dark', { + base: 'vs-dark', inherit: true, rules: [{ background: '1c2228' }], + colors: { 'editor.background': '#1c2228', 'minimap.background': '#1c2228', 'editorGutter.background': '#1c2228' } + }); + }; + + const mountEditor = () => { + if (monacoEditor || typeof require === 'undefined') return; + require.config({ paths: { vs: '/webjars/monaco-editor/min/vs' } }); + require(['vs/editor/editor.main'], (monaco) => { + const container = document.getElementById('intent-monaco'); + if (!container) return; + defineMonacoThemes(monaco); + monacoEditor = monaco.editor.create(container, { + value: $scope.text || '', + language: 'yaml', + theme: monacoThemeFor(), + automaticLayout: true, + fontSize: 13, + tabSize: 2, + insertSpaces: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + renderWhitespace: 'selection', + }); + // Monaco owns the text now; push every edit back into $scope.text and run the same + // dirty-tracking + debounced re-parse the textarea's ng-change used to drive. + monacoEditor.onDidChangeModelContent(() => { + $scope.$evalAsync(() => { + $scope.text = monacoEditor.getValue(); + handleTextChanged(); + }); + }); + themingHub.onThemeChange((theme) => monaco.editor.setTheme(monacoThemeFor(theme))); + }); + }; + + // ----- Live preview -------------------------------------------------------- + + const handleTextChanged = () => { + 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 (mxGraph) ------------------------------------------- + // One read-only mxGraph per section: an ER-style diagram for the entities and one top-down + // flowchart per process. Each graph paints fixed-colour cells on a transparent canvas, so the + // whole pane tracks the IDE theme through its CSS background while the cells stay legible in both. + + const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + + const diagrams = []; // live mxGraph instances, destroyed before each re-render + + const teardown = () => { + while (diagrams.length) diagrams.pop().destroy(); + const host = document.getElementById('intent-diagrams'); + if (host) host.innerHTML = ''; + }; + + // A read-only graph wired into a freshly created, titled container appended to the diagram host. + const newSection = (title) => { + const host = document.getElementById('intent-diagrams'); + const heading = document.createElement('h4'); + heading.className = 'intent-section-title'; + heading.textContent = title; + host.appendChild(heading); + const container = document.createElement('div'); + container.className = 'intent-diagram'; + host.appendChild(container); + + const graph = new mxGraph(container); + graph.setHtmlLabels(true); + graph.setEnabled(false); // read-only: no selection, editing or connecting + graph.setTooltips(false); + graph.setPanning(false); + graph.setCellsLocked(true); + graph.border = 16; + graph.keepEdgesInBackground = true; + diagrams.push(graph); + return graph; + }; + + const nodeStyle = (fill) => `rounded=1;whiteSpace=wrap;html=1;fillColor=${fill};strokeColor=${fill};fontColor=${COLOR.label};verticalAlign=top;spacingTop=2;arcSize=8;`; + const shapeStyle = (shape, fill) => `shape=${shape};whiteSpace=wrap;html=1;fillColor=${fill};strokeColor=${fill};fontColor=${COLOR.label};`; + const edgeStyle = (dashed) => `edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=${COLOR.edge};fontColor=${COLOR.edge};endArrow=open;${dashed ? 'dashed=1;' : ''}`; + + // Bring the laid-out graph into view: layouts place cells at arbitrary (often negative) + // coordinates, so translate the view to seat the content's top-left at the border (otherwise + // cells fall off the left/top edge and get clipped), then size the container to the content + // height so it shows at natural size and the pane scrolls. + const fitIntoView = (graph, container) => { + const cells = graph.getChildCells(graph.getDefaultParent(), true, true); + const bbox = graph.getBoundingBoxFromGeometry(cells, true); + if (!bbox) return; + graph.view.setTranslate(graph.border - bbox.x, graph.border - bbox.y); + container.style.height = `${Math.ceil(bbox.height) + 2 * graph.border}px`; + container.scrollLeft = 0; + container.scrollTop = 0; + }; + + // Entity card: a blue panel whose HTML label is the entity name over its field list (PK marked). + const entityLabel = (entity) => { + const fields = (entity.fields || []).filter(f => f && f.name); + const rows = fields.map((f) => { + const type = escapeHtml(f.type || 'string'); + const pk = f.primaryKey ? ' PK' : ''; + return `
${escapeHtml(f.name)} : ${type}${pk}
`; + }).join(''); + return `
${escapeHtml(entity.name)}
` + + `
${rows}
`; + }; + + const renderEntities = () => { + const entities = $scope.model.entities.filter(e => e && e.name); + if (!entities.length) return; + const graph = newSection('Entities'); + const container = graph.container; + const parent = graph.getDefaultParent(); + graph.getModel().beginUpdate(); + try { + const byName = {}; + for (const entity of entities) { + const height = 30 + 18 * (entity.fields || []).filter(f => f && f.name).length; + byName[entity.name] = graph.insertVertex(parent, null, entityLabel(entity), 0, 0, 200, Math.max(height, 48), nodeStyle(COLOR.entity)); + } + for (const entity of entities) { + for (const relation of (entity.relations || [])) { + if (!relation || !relation.to || !byName[relation.to]) continue; + // A required relation is drawn solid, an optional one dashed. + graph.insertEdge(parent, null, relation.name || '', byName[entity.name], byName[relation.to], edgeStyle(!relation.required)); + } + } + // Hierarchical (left-to-right), the same layout the processes use: it assigns layers so + // the entity cards never overlap - unlike the organic layout, which collapsed every card + // onto the same spot because they all start at the origin. + const layout = new mxHierarchicalLayout(graph, mxConstants.DIRECTION_WEST); + layout.intraCellSpacing = 40; + layout.interRankCellSpacing = 80; + layout.execute(parent); + } finally { + graph.getModel().endUpdate(); + } + fitIntoView(graph, container); + }; + + // Mirrors BpmnIntentGenerator: a linear chain through the declared steps; a decision emits a + // labelled conditioned edge to `then` and routes its default edge to `else` (falling back to the + // next step in the chain); `end`-kind steps collapse into the single end node. + const renderProcess = (process) => { + const steps = (process.steps || []).filter(s => s && s.name); + const graph = newSection('Process: ' + process.name); + const container = graph.container; + const parent = graph.getDefaultParent(); + graph.getModel().beginUpdate(); + try { + const start = graph.insertVertex(parent, null, 'start', 0, 0, 60, 40, shapeStyle('ellipse', COLOR.terminal)); + const end = graph.insertVertex(parent, null, 'end', 0, 0, 60, 40, shapeStyle('ellipse', COLOR.terminal)); + const byName = {}; + for (const step of steps) { + if (String(step.kind).toLowerCase() === 'end') { byName[step.name] = end; continue; } + if (step.kind === 'decision') byName[step.name] = graph.insertVertex(parent, null, step.name, 0, 0, 120, 70, shapeStyle('rhombus', COLOR.decision)); + else if (step.kind === 'serviceTask' || step.kind === 'script') byName[step.name] = graph.insertVertex(parent, null, step.name, 0, 0, 140, 44, nodeStyle(COLOR.service)); + else byName[step.name] = graph.insertVertex(parent, null, step.name, 0, 0, 140, 44, nodeStyle(COLOR.entity)); + } + const vertexFor = (name) => { + if (String(name).toLowerCase() === 'end') return end; + return byName[name] || end; + }; + + const chain = [start]; + for (const step of steps) { + const v = String(step.kind).toLowerCase() === 'end' ? end : byName[step.name]; + if (chain[chain.length - 1] !== v) chain.push(v); + } + 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 => byName[s.name] === source && s.kind === 'decision'); + if (decision) { + const args = decision.args || {}; + if (args['else']) target = vertexFor(args['else']); + graph.insertEdge(parent, null, '', source, target, edgeStyle(true)); + if (args['if'] && args['then']) { + graph.insertEdge(parent, null, String(args['if']), source, vertexFor(args['then']), edgeStyle(false)); + } + } else { + graph.insertEdge(parent, null, '', source, target, edgeStyle(false)); + } + } + + const layout = new mxHierarchicalLayout(graph, mxConstants.DIRECTION_NORTH); + layout.intraCellSpacing = 30; + layout.interRankCellSpacing = 60; + layout.execute(parent); + } finally { + graph.getModel().endUpdate(); + } + fitIntoView(graph, container); + }; + + const render = () => { + const host = document.getElementById('intent-diagrams'); + if (!host || typeof mxGraph === 'undefined') return; + teardown(); + renderEntities(); + for (const process of $scope.model.processes) { + if (process && process.name) renderProcess(process); + } + if (!diagrams.length) { + const empty = document.createElement('div'); + empty.className = 'intent-diagram-empty'; + empty.textContent = 'Nothing to diagram yet - declare entities or processes.'; + host.appendChild(empty); + } + }; + + // ----- 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.$on('$destroy', () => { + teardown(); + if (monacoEditor) { + monacoEditor.dispose(); + monacoEditor = null; + } + }); + + $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/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json new file mode 100644 index 00000000000..e63dbe064e7 --- /dev/null +++ b/components/ui/editor-intent/src/main/resources/META-INF/dirigible/editor-intent/project.json @@ -0,0 +1,5 @@ +{ + "guid": "editor-intent", + "dependencies": [], + "actions": [] +} diff --git a/components/ui/view-preview/src/main/resources/META-INF/dirigible/view-preview/js/preview.js b/components/ui/view-preview/src/main/resources/META-INF/dirigible/view-preview/js/preview.js index df0ab98e955..d65ab8e5421 100644 --- a/components/ui/view-preview/src/main/resources/META-INF/dirigible/view-preview/js/preview.js +++ b/components/ui/view-preview/src/main/resources/META-INF/dirigible/view-preview/js/preview.js @@ -213,6 +213,7 @@ previewView.controller('PreviewController', ($scope, $document, ButtonStates) => case 'camel': case 'form': case 'report': + case 'intent': return; default: url += '/web'; 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 b44e4b7d236..6b7e4fadde4 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/modules/pom.xml b/modules/pom.xml index ecd388638c0..545e82651ef 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 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 00000000000..f7696e8e3de --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -0,0 +1,436 @@ +/* + * 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.equalTo; +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.assertTrue; + +import java.nio.charset.StandardCharsets; + +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 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 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 = "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 + description: Order management with approval workflow + version: 1 + + entities: + - name: Country + 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: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string, required: true, length: 200 } + - { name: active, type: boolean, defaultValue: "true" } + relations: + - { name: country, kind: manyToOne, to: Country } + - { name: orders, kind: oneToMany, to: Order } + + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: orderDate, type: date, required: true } + - { name: total, type: decimal } + relations: + - { name: customer, kind: manyToOne, to: Customer } + - { name: items, kind: oneToMany, to: OrderItem } + + - name: OrderItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: quantity, type: integer, required: true } + relations: + - { name: order, kind: manyToOne, to: Order, composition: true } + + processes: + - name: OrderApproval + trigger: { onCreate: Order } + steps: + - name: managerReview + kind: userTask + args: { assignee: order-manager, form: ApproveOrder } + - name: bigOrder + kind: decision + args: { if: "amount > 10000", then: cfoReview, else: notifyCustomer } + - 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, total] + actions: [approve, reject] + + reports: + - name: OrdersByCustomer + source: Order + dimensions: [customer] + measures: ["count(*)", "sum(total)"] + - name: BigOrderItems + source: OrderItem + description: Order items with quantity over one, with their order date + dimensions: [order.orderDate, quantity] + filter: "quantity > 1" + + permissions: + - { role: Sales, description: Sales staff, can: [Customer:read, Order:create] } + - { role: Manager, description: Sales manager, can: [Order:approve] } + + seeds: + - name: countries + entity: Country + rows: + - { id: 1, name: Afghanistan, code2: AF } + - { id: 2, name: Albania, code2: AL } + """; + + @Autowired + private IRepository repository; + + @Autowired + private RestAssuredExecutor restAssuredExecutor; + + @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(2)) + .body("permissions", hasSize(2)) + .body("seeds[0].rows", hasSize(2))); + } + + @Test + void parse_reports_every_validation_issue_at_once() { + String broken = """ + name: broken + entities: + - name: Customer + fields: + - { name: id, type: integer, 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 parse_rejects_a_trigger_to_an_unknown_entity() { + String yaml = """ + name: badtrigger + entities: + - name: Order + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + processes: + - name: Approve + trigger: { onCreate: Nowhere } + steps: + - { name: done, kind: end } + """; + restAssuredExecutor.execute(() -> given().contentType("text/plain") + .body(yaml) + .when() + .post(PARSE_URL) + .then() + .statusCode(422) + .body("issues", hasItem( + "process [Approve] trigger onCreate references unknown entity [Nowhere]"))); + } + + @Test + void parse_rejects_a_non_integer_primary_key() { + String yaml = """ + name: badpk + entities: + - name: Customer + fields: + - { name: id, type: uuid, primaryKey: true, generated: true } + """; + restAssuredExecutor.execute(() -> given().contentType("text/plain") + .body(yaml) + .when() + .post(PARSE_URL) + .then() + .statusCode(422) + .body("issues", hasItem( + "entity [Customer] primary-key field [id] must be an integer type (integer/int/long) - identifiers are integer by convention, got [uuid]"))); + } + + @Test + 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_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 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 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 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("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 composition manyToOne to Order"); + assertTrue(edmXml.contains("relationshipName=\"OrderItem_Order\""), + "relationshipName (the FK constraint name) should be _ like the codbex .model"); + assertTrue(edmXml.contains("relationshipEntityName=\"Order\"") && edmXml.contains("relationshipEntityPerspectiveName=\"Order\""), + "FK property must carry relationshipEntityName + relationshipEntityPerspectiveName - the dropdown data-service URL is built from them"); + assertTrue(edmXml.contains("relationshipType=\"ASSOCIATION\"") && edmXml.contains("relationshipCardinality=\"n_1\""), + "a non-composition manyToOne (e.g. Order->Customer) should be an ASSOCIATION with n_1 cardinality"); + assertTrue(edmXml.contains("name=\"Id\""), "property names should be PascalCase (Dirigible/codbex convention): id -> Id"); + assertTrue(edmXml.contains("auditType=\"NONE\""), "properties should carry auditType=\"NONE\" like codbex EDMs"); + assertTrue(edmXml.contains("isRequiredProperty=\"true\""), + "a required field/FK should carry isRequiredProperty - the REST controller's required validation keys on it"); + assertTrue(edmXml.contains("widgetDropDownKey=\"Id\""), + "dropdown key should be the target entity's actual PK property name, PascalCased (Id)"); + assertTrue(edmXml.contains("referenced=\"Customer\""), "EDM should carry the Order->Customer relation"); + assertTrue(edmXml.contains("dataName=\"CUSTOMER_COUNTRY\""), + "Customer->Country FK should materialize as a CUSTOMER_COUNTRY column on Customer"); + // OrderApproval has trigger { onCreate: Order }, so Order gains a ProcessId back-reference. + assertTrue(edmXml.contains("name=\"ProcessId\"") && edmXml.contains("dataName=\"ORDER_PROCESS_ID\""), + "an entity a process starts on create should get a ProcessId back-reference property"); + + // The EDM editor renders the canvas ONLY from mxGraphModel - without it the editor opens + // empty. Assert the diagram block, an entity vertex, and a relation edge are present. + assertTrue(edmXml.contains(""), "EDM must carry an mxGraphModel diagram or the editor renders an empty canvas"); + assertTrue(edmXml.contains("style=\"entity\""), "mxGraphModel must contain entity vertices"); + assertTrue(edmXml.contains(" 10000}"), "BPMN should embed the decision's if expression"); + + // The Flowable/Oryx modeler renders the canvas ONLY from the diagram interchange - without it + // the editor opens empty. Assert the diagram block, a node shape, and a flow edge are present. + assertTrue(body.contains(" OrderDate)"); + assertTrue(body.contains("\"type\": \"positive\""), "form should mark the approve button as positive"); + assertTrue(body.contains("onApproveClicked"), "form code should declare the approve handler stub"); + } + + private void assertReport() { + String body = contentOf("OrdersByCustomer.report"); + assertTrue(body.contains("\"name\": \"OrdersByCustomer\""), "report should carry its declared name"); + assertTrue(body.contains("\"alias\": \"Order\""), "report alias should be the source entity"); + assertTrue(body.contains("\"table\": \"ORDERS_ORDER\""), + "report table should be the same intent-prefixed table name the EDM declares as dataName"); + 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"); + // The query is materialised SQL (not left empty): SELECT ... FROM as ... GROUP BY. + assertTrue(body.contains("SELECT ") && body.contains("FROM ORDERS_ORDER as Order") && body.contains("GROUP BY"), + "report query should be a materialised SQL statement with GROUP BY, not empty"); + assertTrue(body.contains("SUM(Order.ORDER_TOTAL)"), "sum(total) should aggregate the qualified ORDER_TOTAL column"); + assertTrue(body.contains("\"roleRead\":"), "report should carry default-role read security"); + // A bare to-one relation dimension (customer) joins the related table and shows its name field, + // grouping by the name - not the raw FK id. + assertTrue(body.contains("INNER JOIN ORDERS_CUSTOMER as Customer ON Order.ORDER_CUSTOMER = Customer.CUSTOMER_ID"), + "a bare relation dimension (customer) should INNER JOIN the related entity"); + assertTrue(body.contains("SELECT Customer.CUSTOMER_NAME as") && body.contains("GROUP BY Customer.CUSTOMER_NAME"), + "the bare relation dimension should select + group by the related entity's name, not its FK id"); + + // A relation.field dimension joins the related table; the filter becomes a qualified WHERE. + String joined = contentOf("BigOrderItems.report"); + assertTrue(joined.contains("INNER JOIN ORDERS_ORDER as Order ON OrderItem.ORDER_ITEM_ORDER = Order.ORDER_ID"), + "a relation.field dimension (order.orderDate) should INNER JOIN the related entity on its FK"); + assertTrue(joined.contains("WHERE OrderItem.ORDER_ITEM_QUANTITY > 1"), + "the intent filter should become a WHERE with the field rewritten to its qualified column"); + } + + 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("\"description\": \"Sales staff\""), "Role descriptions should be carried through"); + } + + 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"); + + 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 removeProject() { + if (repository.hasCollection(PROJECT_PATH)) { + repository.removeCollection(PROJECT_PATH); + } + } +} diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentEditorLoadsIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentEditorLoadsIT.java new file mode 100644 index 00000000000..98a8e0826d1 --- /dev/null +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/ui/tests/IntentEditorLoadsIT.java @@ -0,0 +1,119 @@ +/* + * 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.ui.tests; + +import java.util.concurrent.TimeUnit; + +import org.awaitility.Awaitility; +import org.eclipse.dirigible.repository.api.IRepository; +import org.eclipse.dirigible.repository.api.IRepositoryStructure; +import org.eclipse.dirigible.tests.base.UserInterfaceIntegrationTest; +import org.eclipse.dirigible.tests.framework.browser.HtmlAttribute; +import org.eclipse.dirigible.tests.framework.browser.HtmlElementType; +import org.eclipse.dirigible.tests.framework.ide.GitPerspective; +import org.eclipse.dirigible.tests.framework.ide.Workbench; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.codeborne.selenide.Condition; +import com.codeborne.selenide.Selenide; + +import org.openqa.selenium.By; + +/** + * Browser smoke test for the Intent Editor. The HTTP-only {@code IntentEngineIT} exercises the + * {@code /parse} and {@code /generate} services exhaustively but cannot catch the editor page + * failing to bootstrap in a browser - which is exactly the class of regression that slips through + * (a missing {@code ng-editor} platform-links category, or the config script not being loaded, both + * leave the services green while the editor is dead). This test clones the + * {@code dirigiblelabs/sample-intent-model} sample project (the same clone-a-real-repo pattern the + * {@code SampleProjectRepositoryIT} subclasses use), opens its {@code app.intent}, and asserts: + *
    + *
  • the file is routed to the Intent Editor (the editor tab appears),
  • + *
  • the AngularJS {@code intentEditor} module actually bootstrapped (its injector resolves - + * directly catching the {@code $injector:modulerr} that a missing dependency causes),
  • + *
  • the Monaco source editor and the live mxGraph diagram render - including the parsed entity's + * label inside the diagram - so the {@code /parse} round-trip and the mxGraph rendering both work + * end to end inside the iframe,
  • + *
  • clicking Generate writes the model files into the workspace project.
  • + *
+ * The diagram uses fixed brand colours that read on both the light and dark themes (like the + * EDM/schema modelers), so it renders identically in either theme and needs no theme-switch probe. + */ +public class IntentEditorLoadsIT extends UserInterfaceIntegrationTest { + + private static final String REPOSITORY_URL = "https://github.com/dirigiblelabs/sample-intent-model.git"; + private static final String PROJECT = "sample-intent-model"; + private static final String INTENT_FILE = "app.intent"; + private static final String GENERATED_EDM_PATH = IRepositoryStructure.PATH_USERS + "/admin/workspace/" + PROJECT + "/library.edm"; + + @Autowired + private IRepository repository; + + @Test + void intentEditor_opens_bootstraps_and_generates() { + ide.openHomePage(); + GitPerspective gitPerspective = ide.openGitPerspective(); + gitPerspective.cloneRepository(REPOSITORY_URL); + + Workbench workbench = ide.openWorkbench(); + workbench.openFile(PROJECT, INTENT_FILE); + + // The file routed to the Intent Editor - its tab is present. + browser.assertElementExistByAttributePatternAndText(HtmlElementType.SPAN, HtmlAttribute.CLASS, "fd-icon-tab-bar__tag", INTENT_FILE); + + // Switch into the editor iframe; the Monaco source editor is the body, not the error page. + browser.findElementInAllFrames(By.cssSelector(".intent-monaco .monaco-editor"), Condition.visible); + + // The AngularJS module bootstrapped - a missing platform-links dependency would have thrown + // $injector:modulerr and left no injector on the ng-app element. + Object bootstrapped = Selenide.executeJavaScript( + "var el = document.querySelector('[ng-app=\"intentEditor\"]');" + "return !!(el && angular.element(el).injector());"); + Assertions.assertTrue(Boolean.TRUE.equals(bootstrapped), + "intentEditor AngularJS module failed to bootstrap inside the editor iframe."); + + // The live mxGraph diagram rendered from the parsed model (proves /parse + mxGraph work + // in-frame): the entities section produces an mxGraph SVG carrying the parsed entity's label. + Selenide.$(By.cssSelector(".intent-diagram svg")) + .shouldBe(Condition.visible); + Selenide.$(By.xpath("//div[contains(@class, 'intent-diagram')]//*[contains(text(), 'Book')]")) + .shouldBe(Condition.visible); + + // Generate writes the model files into the workspace project. + Selenide.$(By.xpath("//button[contains(normalize-space(.), 'Generate')]")) + .shouldBe(Condition.enabled) + .click(); + + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .until(() -> repository.getResource(GENERATED_EDM_PATH) + .exists()); + + // The generated EDM must render its entity boxes - the canvas was empty before the + // mxGraphModel layout was added. Switch into the EDM editor's graph frame and assert an + // entity title is drawn. + workbench.openFile("library.edm"); + browser.assertElementExistByAttributePatternAndText(HtmlElementType.SPAN, HtmlAttribute.CLASS, "fd-icon-tab-bar__tag", + "library.edm"); + browser.findElementInAllFrames(By.id("graphContainer"), Condition.visible); + Selenide.$(By.xpath("//*[contains(text(), 'Book')]")) + .shouldBe(Condition.visible); + + // The generated BPMN must render its process nodes - likewise empty before the bpmndi layout. + workbench.openFile("LoanApproval.bpmn"); + browser.assertElementExistByAttributePatternAndText(HtmlElementType.SPAN, HtmlAttribute.CLASS, "fd-icon-tab-bar__tag", + "LoanApproval.bpmn"); + browser.findElementInAllFrames(By.id("canvasSection"), Condition.visible); + Selenide.$(By.xpath("//*[contains(text(), 'librarianReview')]")) + .shouldBe(Condition.visible); + } +}