From 2d24ac776c8cbc62b403d1e9303f55d3e0a25564 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:33:00 +0000 Subject: [PATCH 1/7] fix(odata): re-fetch $metadata on CREATE OR MODIFY ODATA CLIENT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modify branch of createODataClient updated every property of a consumed OData client except the cached contract. Only the create path fetched $metadata, so svc.Metadata / svc.MetadataHash kept the snapshot taken when the client was first created. Reported against mxcli-formula1: the backend's F1LiveNowApi gained two entity sets (Trace, Stints) and four attributes on Order, the contract file was refreshed from the running backend, and re-running the CREATE OR MODIFY reported "Unchanged OData client". SHOW CONTRACT ENTITIES kept listing three entity types and the CREATE OR MODIFY EXTERNAL ENTITIES that followed imported the old shape without saying so. The only way out was DROP ODATA CLIENT + recreate, which invalidates the client ID the existing external entities point at. The modify branch now normalizes MetadataUrl the way the create path does — it stored the raw value, so a relative './contracts/x.xml' ended up as a URL neither Studio Pro nor fetchODataMetadata can open — and re-reads the contract before writing. Three behaviours are deliberate: * The refresh runs off the *effective* URL, so a CREATE OR MODIFY that omits MetadataUrl still converges the cache. * A failed fetch is a warning and keeps the cached contract. Losing one that was merely unreachable is worse than serving a stale one. * An unchanged contract leaves the service untouched, so the write is elided (ADR-0008) and the statement still reports "Unchanged". ALTER ODATA CLIENT SET MetadataUrl has the same staleness and is not changed here: it carries no design-time credentials, so a blind re-fetch of an authenticated URL would warn where it used to be silent. Verified on mxbuild 11.13.0 against a real project: 3 entity types cached on create, "Unchanged" on a re-run with the file untouched, "Refreshed $metadata: 5 entity types" after the contract was replaced, all five sets imported by CREATE OR MODIFY EXTERNAL ENTITIES, mx check 0 errors. The unit tests fail with the reported symptom when the refresh is stubbed out, while the unchanged- contract control passes either way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/fix-issue.md | 1 + .claude/skills/mendix/odata-data-sharing.md | 35 +++ .../bug-tests/contracts/stale-contract-v1.xml | 28 +++ .../bug-tests/contracts/stale-contract-v2.xml | 46 ++++ .../odata-client-stale-contract-on-modify.mdl | 59 +++++ mdl/executor/cmd_odata.go | 104 +++++++-- .../cmd_odata_metadata_refresh_test.go | 205 ++++++++++++++++++ 7 files changed, 460 insertions(+), 18 deletions(-) create mode 100644 mdl-examples/bug-tests/contracts/stale-contract-v1.xml create mode 100644 mdl-examples/bug-tests/contracts/stale-contract-v2.xml create mode 100644 mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl create mode 100644 mdl/executor/cmd_odata_metadata_refresh_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 7e3968e61..64f02e84c 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -608,3 +608,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `DELETE_BEHAVIOR PREVENT` (or `DELETE_IF_NO_REFERENCES`, or `DELETE_AND_REFERENCES`) reports `Modified association` and stores `DeleteMeButKeepReferences`, destroying whatever the association had — and `DESCRIBE ASSOCIATION` emits `delete_behavior DELETE_CASCADE`, which the parser then rejects | `buildDeleteBehavior` read only `db.CASCADE()`; the other four of the rule's five tokens fell through to the zero value `ast.DeleteKeepReferences`. A **legal** behaviour, so nothing below could tell it had been substituted. Underneath it, `ALTER ASSOCIATION SET` built the stored value as `DeleteBehaviorType(s.DeleteBehavior.String())`, and `String()` spells prevent `"DeleteIfNoReferences"` where Mendix writes `"DeleteMeIfNoReferences"` — so fixing the visitor alone put an **out-of-domain enum** on disk | `mdl/visitor/visitor_helpers.go` (`buildDeleteBehavior`), then `mdl/executor/cmd_associations.go` (`storageDeleteBehavior`) | Read **every** token the grammar rule admits — a `default:` arm returning a plausible value turns a parse gap into silent data loss. Route all storage conversions through one function; a `String()` returning storage-ish names is a trap, not an encoder. Emit from DESCRIBE only spellings the lexer has (`DELETE_AND_REFERENCES`, not `DELETE_CASCADE`) and prove it by feeding the output back through `visitor.Build`. **`mx check` is not the oracle here**: measured on mxbuild 11.6.6, a project carrying `DeleteIfNoReferences` builds with 0 errors exactly like a correct one — only Studio Pro refuses it, so assert the stored value is one of Mendix's three, not merely the one you asked for (upstream #901) | | `CREATE OR REPLACE WORKFLOW` silently deletes a boundary event (timer + its whole handler flow) or an event sub-process that the script does not restate — `exec` reports "Created workflow" and exit 0, and `mx check` afterwards reports 0 errors because the result is a valid workflow that simply no longer does what it did. `DESCRIBE WORKFLOW` does not show the construct either, so nothing reveals the loss | Two separate defects. (1) The **default (modelsdk) engine's workflow reader had no boundary-event support at all** — `grep BoundaryEvent mdl/backend/modelsdk/workflow_read.go` → 0, while `sdk/mpr/parser_workflow.go` → 17. Both engines *write* them, so a boundary event mxcli itself had just written read back as absent. (2) A rewrite rebuilds from the statement, so anything unstated is dropped, and no guard existed — unlike the queued-call and validation-rule paths | `mdl/backend/modelsdk/workflow_read.go` (`boundaryEventsFromGen`, wired into the 6 gen types with `BoundaryEventsItems`); `mdl/executor/validate_workflow_rewrite.go` (`checkNoDroppedWorkflowConstructs`, called from `cmd_workflows_write.go`); `mdl/executor/cmd_workflows.go` (clause order) | **Read the stored side from the RAW unit, not the semantic model** — the reader is what was blind, and a guard sharing its blind spot cannot see what it protects; `GetRawUnit` also covers constructs no engine models yet (event sub-processes exist in `modelsdk/gen` but have no semantic type, reader or writer anywhere). Match the `$Type` by **substring** so all three timer variants and any later one are caught. Authorable constructs get the queue treatment (restate them and the rewrite proceeds); unauthorable ones refuse outright. **Fixing a reader can expose a describer bug**: with boundary events finally readable, `describe → exec` stopped parsing, because the call-microflow describer emitted `boundary event` BEFORE `outcomes` and the grammar requires the reverse — pre-existing on legacy, invisible while the default engine emitted neither. Always re-run a describe→re-exec round trip after widening a reader. Issue #948 | | A workflow that calls a microflow existing nowhere passes `mxcli check --references` with "All references valid" and is written by `exec` with exit 0; only native `mx check` catches it (**CE1613** "The selected microflow ... no longer exists"). The identical mistake inside a plain microflow body IS caught | `validateWithContext`'s `CreateWorkflowStmt` case called only `validateWorkflowParameterMappings`, whose own comment defers a target that is not in the project to "the missing-reference check" — **which was never written**. `validateFlowBodyReferences` (which does catch it) is wired only to `CreateMicroflowStmt`/`CreateNanoflowStmt`. `AlterWorkflowStmt` had **no case at all** and fell to `default: skip validation` | `mdl/executor/validate_workflow_refs.go` (`validateWorkflowReferences`, `validateWorkflowStatementRefs`, `validateAlterWorkflowRefs`); `mdl/executor/validate.go` (both switch cases, `scriptContext.workflows`, `collectDefinitions`/`collectSingle`/`allNames`); `mdl/executor/helpers.go` (`buildWorkflowQualifiedNames`); exec-side guards in `cmd_workflows_write.go` + `cmd_alter_workflow.go` | **The gap is never one reference kind** — probe them all before fixing. Here *every* reference in a workflow was unvalidated: call microflow, call workflow, user task page, targeting microflow, context entity, and the workflow's own module (checked for a microflow, not for a workflow — and `exec` auto-creates a module, so `check` was the only guard against a typo silently making one). **`check` and `exec` run different passes**: `check --references` runs `validateProgram`, `exec` does not, so a validator wired only into the switch leaves exec writing the broken model — the guard must ALSO be called from the statement handler, before `findOrCreateModule` (same shape as #833). Pass `nil` for the scriptContext there: exec applies statements one at a time, so an earlier statement's output is already in the project. **The real risk is false positives** — verify by diffing error counts against a baseline binary over every workflow script in `mdl-examples/`, with the referenced modules actually present, and exempt `System.*` (`isBuiltinModuleEntity`) or every built-in target is reported missing. Issue #943 | +| `CREATE OR MODIFY ODATA CLIENT` reports `Unchanged OData client` after the contract file was refreshed from the running backend — `SHOW CONTRACT ENTITIES` keeps listing the old entity types, and the `CREATE OR MODIFY EXTERNAL ENTITIES` that follows imports the old shape with no warning. `DROP ODATA CLIENT` + recreate is the only way out, and it invalidates the client ID the existing external entities point at | The modify branch updated every property **except the cached contract**. Only the create path fetched `$metadata`; `svc.Metadata` / `svc.MetadataHash` kept the snapshot taken when the client was first created. The same branch also stored `stmt.MetadataUrl` raw, skipping the `NormalizeURL` the create path applies, so a relative `./contracts/x.xml` ended up as a URL neither Studio Pro nor `fetchODataMetadata` can open | `mdl/executor/cmd_odata.go` (`refreshCachedMetadata`, `normalizeMetadataURL`, `contractSummary`, called from the `stmt.CreateOrModify` branch of `createODataClient`); test `mdl/executor/cmd_odata_metadata_refresh_test.go`; repro `mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl` | **Refresh from the *effective* URL, not the statement's** — a `CREATE OR MODIFY` that omits `MetadataUrl` still has to converge the cache, or the idiomatic re-run silently keeps the stale contract. **A failed fetch must keep what is cached** (warning, not error): losing a contract that was merely unreachable is worse than serving a stale one, and there is nothing to fall back on. **Leave `svc` untouched when the hash matches**, or ADR-0008 elision breaks and every re-run churns the document — the unchanged-contract control is what proves this, and it passes both before and after the fix, so it is the half of the test that keeps the other half honest. `ALTER ODATA CLIENT SET MetadataUrl` has the same staleness and is deliberately **not** fixed here: it carries no design-time credentials, so a blind re-fetch of an authenticated URL would warn where it used to be silent. `mx check` is no oracle for any of this — the stale project builds with 0 errors, because a client caching an old contract is perfectly valid | diff --git a/.claude/skills/mendix/odata-data-sharing.md b/.claude/skills/mendix/odata-data-sharing.md index 08d37083d..8cef10c17 100644 --- a/.claude/skills/mendix/odata-data-sharing.md +++ b/.claude/skills/mendix/odata-data-sharing.md @@ -31,6 +31,41 @@ This skill covers how to use OData services to share data between Mendix applica - With project loaded (`-p` flag or REPL): relative paths are resolved against the `.mpr` file's directory - Without project: relative paths are resolved against the current working directory +## Refreshing the Cached Contract + +mxcli caches the `$metadata` document in the client so the model rebuilds without +the service running. That cache is a snapshot, and a consumed service that gains +entity sets makes it stale — the file on disk has five, the client still answers +three. + +`CREATE OR MODIFY ODATA CLIENT` re-reads the contract every time it runs, so +refreshing is a re-run of the statement you already have: + +```mdl +-- after refreshing ./contracts/live-now-metadata.xml from the running backend +CREATE OR MODIFY ODATA CLIENT F1Now.NowApi ( + ODataVersion: OData4, + MetadataUrl: './contracts/live-now-metadata.xml', + Timeout: 300, + ServiceUrl: '@F1Now.ApiLocation' +); +``` + +Read the verb it prints — it tells you which happened: + +| Output | Meaning | +|--------|---------| +| `Modified OData client: …` + `Refreshed $metadata: 5 entity types, 0 actions` | The contract changed and the client now carries the new one | +| `Unchanged OData client: …` | The contract is identical, so nothing was written | +| `Warning: could not refresh $metadata: …` | The contract could not be read; the **previously cached one is kept**, so re-run once it is reachable | + +Then re-import: `CREATE OR MODIFY EXTERNAL ENTITIES FROM F1Now.NowApi` maps the +new entity sets. Do **not** `DROP ODATA CLIENT` and recreate it to force a +refresh — that invalidates the client ID the existing external entities point at. + +Note that `ALTER ODATA CLIENT SET MetadataUrl = …` does *not* re-fetch. Use +`CREATE OR MODIFY` when the contract is what changed. + **Use Cases for Local Metadata:** - **Offline development** — no network access required - **Testing and CI/CD** — reproducible builds with metadata snapshots diff --git a/mdl-examples/bug-tests/contracts/stale-contract-v1.xml b/mdl-examples/bug-tests/contracts/stale-contract-v1.xml new file mode 100644 index 000000000..b94e518a3 --- /dev/null +++ b/mdl-examples/bug-tests/contracts/stale-contract-v1.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl-examples/bug-tests/contracts/stale-contract-v2.xml b/mdl-examples/bug-tests/contracts/stale-contract-v2.xml new file mode 100644 index 000000000..307654556 --- /dev/null +++ b/mdl-examples/bug-tests/contracts/stale-contract-v2.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl b/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl new file mode 100644 index 000000000..943938f5b --- /dev/null +++ b/mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl @@ -0,0 +1,59 @@ +-- ============================================================================ +-- CREATE OR MODIFY ODATA CLIENT left the cached $metadata at the snapshot +-- taken when the client was created. +-- +-- Reported against mxcli-formula1: the backend's F1LiveNowApi gained two entity +-- sets (Trace, Stints) and four attributes on Order. The contract file was +-- refreshed from the running backend and contained all five sets, but: +-- +-- * re-running the CREATE OR MODIFY below reported "Unchanged OData client", +-- * SHOW CONTRACT ENTITIES kept listing three entity types, +-- * the CREATE OR MODIFY EXTERNAL ENTITIES that followed imported the old +-- shape and silently missed the new sets. +-- +-- The only way out was DROP ODATA CLIENT + recreate, which is destructive: it +-- invalidates the client ID the existing external entities point at. +-- +-- The modify branch updated every property except the one that changes most +-- often. It now normalizes MetadataUrl and re-fetches the contract, the same +-- way the create path does. +-- +-- Manual repro (needs a project; MetadataUrl resolves against the .mpr): +-- +-- mkdir -p /contracts +-- cp mdl-examples/bug-tests/contracts/stale-contract-v1.xml \ +-- /contracts/live-now-metadata.xml +-- mxcli exec mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl \ +-- -p /app.mpr # -> Cached $metadata: 3 entity types +-- +-- cp mdl-examples/bug-tests/contracts/stale-contract-v2.xml \ +-- /contracts/live-now-metadata.xml +-- mxcli exec mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl \ +-- -p /app.mpr # -> Refreshed $metadata: 5 entity types +-- +-- mxcli -p /app.mpr -c "show contract entities from F1Now.NowApi" +-- # -> lists Trace and Stints +-- +-- Before the fix the second run printed "Unchanged OData client" and the SHOW +-- still listed three. Running the script twice with the file left alone is the +-- control: it must stay "Unchanged", or every re-run churns the document and +-- breaks the idempotence ADR-0008 guarantees. +-- +-- Regression coverage: mdl/executor/cmd_odata_metadata_refresh_test.go +-- ============================================================================ + +create or modify module F1Now; + +create or modify constant F1Now.ApiLocation + type String + default 'http://localhost:8080/odata/livenow/'; + +-- Live timing feed. Re-run this statement after refreshing +-- contracts/live-now-metadata.xml and the cached contract follows it. +create or modify odata client F1Now.NowApi ( + Version: '1.0.0', + ODataVersion: OData4, + MetadataUrl: './contracts/live-now-metadata.xml', + ServiceUrl: '@F1Now.ApiLocation', + Timeout: '300' +); diff --git a/mdl/executor/cmd_odata.go b/mdl/executor/cmd_odata.go index 1a31d0eb2..653442f75 100644 --- a/mdl/executor/cmd_odata.go +++ b/mdl/executor/cmd_odata.go @@ -983,7 +983,11 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error svc.ODataVersion = stmt.ODataVersion } if stmt.MetadataUrl != "" { - svc.MetadataUrl = stmt.MetadataUrl + normalized, nerr := normalizeMetadataURL(ctx, stmt.MetadataUrl) + if nerr != nil { + return fmt.Errorf("failed to normalize MetadataUrl: %w", nerr) + } + svc.MetadataUrl = normalized } if stmt.TimeoutExpression != "" { svc.TimeoutExpression = stmt.TimeoutExpression @@ -1048,6 +1052,17 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error } } } + // Re-fetch $metadata. The cached contract is a snapshot taken + // when the client was created; leaving it alone here made + // CREATE OR MODIFY converge every property except the one that + // changes most often. A service that gains entity sets then + // reports "Unchanged OData client", and the CREATE EXTERNAL + // ENTITIES that follows imports the old shape silently + // (mxcli-formula1: F1LiveNowApi gained Trace and Stints, and + // the only way out was DROP + recreate, which invalidates the + // client ID the external entities point at). + refreshed := refreshCachedMetadata(ctx, svc, stmt) + if err := ctx.Backend.UpdateConsumedODataService(svc); err != nil { return mdlerrors.NewBackend("update OData client", err) } @@ -1060,6 +1075,11 @@ func createODataClient(ctx *ExecContext, stmt *ast.CreateODataClientStmt) error } invalidateHierarchy(ctx) ctx.ReportMutation("Modified", "OData client: %s.%s", modName, svc.Name) + if refreshed { + if sum := contractSummary(svc.Metadata); sum != "" { + fmt.Fprintf(ctx.Output, " Refreshed $metadata: %s\n", sum) + } + } return nil } return mdlerrors.NewAlreadyExistsMsg("OData client", modName+"."+svc.Name, fmt.Sprintf("OData client already exists: %s.%s (use create or modify to update)", modName, svc.Name)) @@ -1137,13 +1157,8 @@ Got: %s`, stmt.ServiceUrl) // Fetch and cache $metadata from the service URL // Normalize local file paths to absolute file:// URLs for Studio Pro compatibility if newSvc.MetadataUrl != "" { - mprDir := "" - if ctx.MprPath != "" { - mprDir = filepath.Dir(ctx.MprPath) - } - // Normalize MetadataUrl: convert relative paths to absolute file:// URLs - normalizedUrl, err := pathutil.NormalizeURL(newSvc.MetadataUrl, mprDir) + normalizedUrl, err := normalizeMetadataURL(ctx, newSvc.MetadataUrl) if err != nil { return fmt.Errorf("failed to normalize MetadataUrl: %w", err) } @@ -1170,21 +1185,74 @@ Got: %s`, stmt.ServiceUrl) } invalidateHierarchy(ctx) fmt.Fprintf(ctx.Output, "Created OData client: %s.%s\n", stmt.Name.Module, stmt.Name.Name) - if newSvc.Metadata != "" { - // Parse to show summary - if doc, err := types.ParseEdmx(newSvc.Metadata); err == nil { - entityCount := 0 - actionCount := 0 - for _, s := range doc.Schemas { - entityCount += len(s.EntityTypes) - } - actionCount = len(doc.Actions) - fmt.Fprintf(ctx.Output, " Cached $metadata: %d entity types, %d actions\n", entityCount, actionCount) - } + if sum := contractSummary(newSvc.Metadata); sum != "" { + fmt.Fprintf(ctx.Output, " Cached $metadata: %s\n", sum) } return nil } +// normalizeMetadataURL resolves a MetadataUrl against the project directory, +// turning a relative path into the absolute file:// URL that Studio Pro stores +// and that fetchODataMetadata expects. An empty URL stays empty. +func normalizeMetadataURL(ctx *ExecContext, rawURL string) (string, error) { + if rawURL == "" { + return "", nil + } + mprDir := "" + if ctx != nil && ctx.MprPath != "" { + mprDir = filepath.Dir(ctx.MprPath) + } + return pathutil.NormalizeURL(rawURL, mprDir) +} + +// contractSummary renders a cached $metadata document as the counts worth +// printing, or "" when there is nothing to parse. +func contractSummary(metadata string) string { + if metadata == "" { + return "" + } + doc, err := types.ParseEdmx(metadata) + if err != nil { + return "" + } + entityCount := 0 + for _, s := range doc.Schemas { + entityCount += len(s.EntityTypes) + } + return fmt.Sprintf("%d entity types, %d actions", entityCount, len(doc.Actions)) +} + +// refreshCachedMetadata re-reads the contract at svc.MetadataUrl into +// svc.Metadata / svc.MetadataHash, reporting whether it actually changed. +// +// A fetch that fails is a warning, not an error, and leaves the cached contract +// in place: losing a contract that was merely unreachable is worse than serving +// a stale one, and the caller has nothing else to fall back on. An unchanged +// contract leaves svc untouched, so the write is elided and the statement +// reports "Unchanged" rather than churning the document. +func refreshCachedMetadata(ctx *ExecContext, svc *model.ConsumedODataService, stmt *ast.CreateODataClientStmt) bool { + if svc == nil || svc.MetadataUrl == "" { + return false + } + auth := metadataAuthFromStmt(ctx, stmt) + metadata, hash, err := fetchODataMetadata(svc.MetadataUrl, auth) + if err != nil { + fmt.Fprintf(ctx.Output, "Warning: could not refresh $metadata: %v\n", err) + for _, hint := range auth.hints() { + fmt.Fprintf(ctx.Output, " %s\n", hint) + } + fmt.Fprintf(ctx.Output, " The client keeps the contract cached earlier, which may be out of date.\n") + return false + } + if metadata == "" || hash == svc.MetadataHash { + return false + } + svc.Metadata = metadata + svc.MetadataHash = hash + svc.Validated = true + return true +} + // alterODataClient handles ALTER ODATA CLIENT command. func alterODataClient(ctx *ExecContext, stmt *ast.AlterODataClientStmt) error { diff --git a/mdl/executor/cmd_odata_metadata_refresh_test.go b/mdl/executor/cmd_odata_metadata_refresh_test.go new file mode 100644 index 000000000..d753358fe --- /dev/null +++ b/mdl/executor/cmd_odata_metadata_refresh_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// edmxWith renders a minimal EDMX document carrying the named entity types, so +// a test can change the contract the way a backend release does — by adding an +// entity set — rather than by editing an opaque blob. +func edmxWith(names ...string) string { + var b strings.Builder + b.WriteString(``) + for _, n := range names { + b.WriteString(``) + } + b.WriteString(``) + return b.String() +} + +// odataModifyFixture wires a mock project holding one OData client whose cached +// contract is whatever the file at contractPath held when the fixture was built +// — i.e. the state CREATE ODATA CLIENT leaves behind. The returned pointer is +// the service the executor mutates; updated records what reached the backend. +type odataModifyFixture struct { + ctx *ExecContext + out *strings.Builder + svc *model.ConsumedODataService + seedHash string + seedCached string + updated **model.ConsumedODataService +} + +// newODataModifyFixture takes the project directory separately from the contract +// path: a relative MetadataUrl resolves against the .mpr, not against wherever +// the contract happens to sit. +func newODataModifyFixture(t *testing.T, projectDir, contractPath string) *odataModifyFixture { + t.Helper() + + cached, hash, err := fetchODataMetadata("file://"+contractPath, nil) + if err != nil { + t.Fatalf("seeding the cached contract failed: %v", err) + } + + mod := mkModule("MyModule") + svc := &model.ConsumedODataService{ + BaseElement: model.BaseElement{ID: nextID("cos")}, + ContainerID: mod.ID, + Name: "NowApi", + ODataVersion: "4.0", + MetadataUrl: "file://" + contractPath, + Metadata: cached, + MetadataHash: hash, + Validated: true, + } + h := mkHierarchy(mod) + withContainer(h, svc.ContainerID, mod.ID) + + var updated *model.ConsumedODataService + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { + return []*model.Module{mod}, nil + }, + ListConsumedODataServicesFunc: func() ([]*model.ConsumedODataService, error) { + return []*model.ConsumedODataService{svc}, nil + }, + UpdateConsumedODataServiceFunc: func(s *model.ConsumedODataService) error { + updated = s + return nil + }, + } + + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h), + withMprPath(filepath.Join(projectDir, "app.mpr"))) + + var sb strings.Builder + ctx.Output = &sb + + return &odataModifyFixture{ + ctx: ctx, out: &sb, svc: svc, + seedHash: hash, seedCached: cached, updated: &updated, + } +} + +func (f *odataModifyFixture) modify(t *testing.T, metadataUrl string) { + t.Helper() + stmt := &ast.CreateODataClientStmt{ + Name: ast.QualifiedName{Module: "MyModule", Name: "NowApi"}, + CreateOrModify: true, + MetadataUrl: metadataUrl, + } + if err := createODataClient(f.ctx, stmt); err != nil { + t.Fatalf("create or modify odata client: %v", err) + } + if *f.updated == nil { + t.Fatal("the modify branch never reached the backend") + } +} + +// mxcli-formula1: CREATE OR REPLACE / CREATE OR MODIFY updated every property of +// an OData client except the cached contract, which stayed at the snapshot taken +// when the client was created. A consumed service that gained entity sets was +// therefore invisible: the refreshed contract file on disk had five, SHOW +// CONTRACT ENTITIES kept showing three, the statement reported "Unchanged", and +// the CREATE EXTERNAL ENTITIES that followed imported the old shape without +// saying so. The only way out was DROP + recreate, which invalidates the client +// ID the existing external entities point at. +func TestCreateOrModifyODataClient_RefreshesCachedContract(t *testing.T) { + dir := t.TempDir() + contract := filepath.Join(dir, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + // The backend gains two entity sets and the contract file is refreshed. + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order", "Trace", "Stints")), 0o600); err != nil { + t.Fatal(err) + } + + f.modify(t, contract) + + got := *f.updated + for _, want := range []string{"Trace", "Stints"} { + if !strings.Contains(got.Metadata, `Name="`+want+`"`) { + t.Errorf("cached contract is missing entity type %q — it is still the snapshot from creation", want) + } + } + if got.MetadataHash == f.seedHash { + t.Error("MetadataHash still matches the contract cached at creation") + } + if !got.Validated { + t.Error("Validated should stay true after a successful refresh") + } + if out := f.out.String(); !strings.Contains(out, "Refreshed $metadata: 5 entity types") { + t.Errorf("no refresh reported to the user; output was:\n%s", out) + } +} + +// The control: an unchanged contract must not be rewritten, or every re-run of a +// script would churn the document and the statement would stop reporting +// "Unchanged". Same code path, only the file is left alone. +func TestCreateOrModifyODataClient_UnchangedContractIsNotRewritten(t *testing.T) { + dir := t.TempDir() + contract := filepath.Join(dir, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Driver", "Order")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + f.modify(t, contract) + + got := *f.updated + if got.Metadata != f.seedCached || got.MetadataHash != f.seedHash { + t.Error("an unchanged contract was rewritten, so the write can no longer be elided") + } + if out := f.out.String(); strings.Contains(out, "Refreshed $metadata") { + t.Errorf("a refresh was reported for an unchanged contract:\n%s", out) + } +} + +// A relative MetadataUrl has to be resolved against the project directory on the +// modify path too. It was stored raw, so a client written with './contracts/…' +// ended up with a URL Studio Pro cannot open and that fetchODataMetadata — which +// documents its input as already normalized — could not read back. +func TestCreateOrModifyODataClient_NormalizesRelativeMetadataUrl(t *testing.T) { + dir := t.TempDir() + contracts := filepath.Join(dir, "contracts") + if err := os.MkdirAll(contracts, 0o755); err != nil { + t.Fatal(err) + } + contract := filepath.Join(contracts, "f1-now-metadata.xml") + if err := os.WriteFile(contract, []byte(edmxWith("Session")), 0o600); err != nil { + t.Fatal(err) + } + + f := newODataModifyFixture(t, dir, contract) + + if err := os.WriteFile(contract, []byte(edmxWith("Session", "Trace")), 0o600); err != nil { + t.Fatal(err) + } + + // The MprPath the fixture set is dir/app.mpr, so this is the spelling a + // script in the project actually uses. + f.modify(t, "./contracts/f1-now-metadata.xml") + + got := *f.updated + if !strings.HasPrefix(got.MetadataUrl, "file://") { + t.Errorf("MetadataUrl = %q, want an absolute file:// URL", got.MetadataUrl) + } + if !strings.Contains(got.Metadata, `Name="Trace"`) { + t.Error("the relative URL did not resolve to the contract on disk, so nothing was refreshed") + } +} From f7e2d099e1847541b742bf1334137d3493ecf4d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 14:41:39 +0000 Subject: [PATCH 2/7] fix(microflows): stop a rewrite stranding the StartEvent (#951) CREATE OR MODIFY MICROFLOW moved every activity to where the script asked and left the start where the PREVIOUS layout had put it, joined to its own first activity by a long, mostly-empty diagonal across the canvas. Measured on a real 11.13.0 project: activities at 360;340, start at 40;200. The cause was the fix for the opposite report. #884 was a describe->exec round-trip MOVING a hand-placed start (145;200 came back as 100;200), fixed by carrying the stored position over on every rewrite -- which then pinned the start of every rewritten flow. Both reports are real and they pull opposite ways, so neither is answerable without asking where the stored value came from. A start at the derived spot (first activity X minus one spacing unit, on its centre line) is mxcli's own arithmetic handed back: it carries no intent, so it is re-derived and follows the activities. A start anywhere else was placed by a person and is kept. @start(x, y) states the position outright and beats both. It goes on the first statement -- the one the start flows into -- because the start has no statement of its own, the same placement @merge already uses for the other implicit node. Without it there was no way to move a start once one had been preserved, which is the other half of what #951 reported. DESCRIBE emits it only for a non-derived start, so an ordinary description does not grow a line restating its own arithmetic while one carrying a hand-placed start round-trips exactly. Emitted from BOTH describers: formatMicroflowActivities and formatMicroflowActivitiesWithSourceMap are near-duplicates, and the first cut patched only the second, so `describe microflow` dropped the line. The test now runs every case through both. Neither `mx check` nor a green build detects any of this: the Mendix model carries no geometry rules, so a stranded start is a valid document that builds and runs and is merely drawn wrong -- 0 errors on mxbuild 11.13.0 before and after. Verified by reading coordinates back off the stored document. Controls, each reverted individually and reproducing only its own symptom: unconditional carry-over -> the stranded start returns; builder arm removed -> @start does nothing; either describer's emission removed -> the lossy round-trip returns; visitor arm removed -> @start does not reach the AST. End to end on a real project: the #951 rewrite now lands the start at 200;340, a hand-placed 145;200 survives a rewrite that does not mention it, and Administration.SaveNewAccount describe->exec leaves all nine coordinates identical. Nanoflows and rules share the builder and the describer, so @start works there too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KTdvGwVewkdgNQGDCxpQPZ --- .claude/skills/fix-issue.md | 1 + .claude/skills/mendix/write-microflows.md | 1 + CHANGELOG.md | 8 + cmd/mxcli/syntax/features_microflow.go | 15 +- docs-site/src/appendixes/quick-reference.md | 1 + docs-site/src/language/microflow-structure.md | 18 ++ .../reference/microflow/create-microflow.md | 12 + docs/01-project/MDL_QUICK_REFERENCE.md | 1 + .../951-microflow-start-event-position.mdl | 96 ++++++++ mdl/ast/ast_microflow.go | 15 ++ mdl/executor/cmd_microflows_builder_graph.go | 16 +- mdl/executor/cmd_microflows_create.go | 35 +-- mdl/executor/cmd_microflows_show.go | 4 + mdl/executor/cmd_microflows_start_position.go | 117 ++++++++++ mdl/executor/cmd_misc.go | 1 + mdl/executor/start_event_position_test.go | 220 ++++++++++++++++-- mdl/executor/validate_microflow.go | 7 +- mdl/visitor/visitor_microflow_statements.go | 17 ++ mdl/visitor/visitor_start_annotation_test.go | 62 +++++ 19 files changed, 606 insertions(+), 41 deletions(-) create mode 100644 mdl-examples/bug-tests/951-microflow-start-event-position.mdl create mode 100644 mdl/executor/cmd_microflows_start_position.go create mode 100644 mdl/visitor/visitor_start_annotation_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 7e3968e61..f54e065a7 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -608,3 +608,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `DELETE_BEHAVIOR PREVENT` (or `DELETE_IF_NO_REFERENCES`, or `DELETE_AND_REFERENCES`) reports `Modified association` and stores `DeleteMeButKeepReferences`, destroying whatever the association had — and `DESCRIBE ASSOCIATION` emits `delete_behavior DELETE_CASCADE`, which the parser then rejects | `buildDeleteBehavior` read only `db.CASCADE()`; the other four of the rule's five tokens fell through to the zero value `ast.DeleteKeepReferences`. A **legal** behaviour, so nothing below could tell it had been substituted. Underneath it, `ALTER ASSOCIATION SET` built the stored value as `DeleteBehaviorType(s.DeleteBehavior.String())`, and `String()` spells prevent `"DeleteIfNoReferences"` where Mendix writes `"DeleteMeIfNoReferences"` — so fixing the visitor alone put an **out-of-domain enum** on disk | `mdl/visitor/visitor_helpers.go` (`buildDeleteBehavior`), then `mdl/executor/cmd_associations.go` (`storageDeleteBehavior`) | Read **every** token the grammar rule admits — a `default:` arm returning a plausible value turns a parse gap into silent data loss. Route all storage conversions through one function; a `String()` returning storage-ish names is a trap, not an encoder. Emit from DESCRIBE only spellings the lexer has (`DELETE_AND_REFERENCES`, not `DELETE_CASCADE`) and prove it by feeding the output back through `visitor.Build`. **`mx check` is not the oracle here**: measured on mxbuild 11.6.6, a project carrying `DeleteIfNoReferences` builds with 0 errors exactly like a correct one — only Studio Pro refuses it, so assert the stored value is one of Mendix's three, not merely the one you asked for (upstream #901) | | `CREATE OR REPLACE WORKFLOW` silently deletes a boundary event (timer + its whole handler flow) or an event sub-process that the script does not restate — `exec` reports "Created workflow" and exit 0, and `mx check` afterwards reports 0 errors because the result is a valid workflow that simply no longer does what it did. `DESCRIBE WORKFLOW` does not show the construct either, so nothing reveals the loss | Two separate defects. (1) The **default (modelsdk) engine's workflow reader had no boundary-event support at all** — `grep BoundaryEvent mdl/backend/modelsdk/workflow_read.go` → 0, while `sdk/mpr/parser_workflow.go` → 17. Both engines *write* them, so a boundary event mxcli itself had just written read back as absent. (2) A rewrite rebuilds from the statement, so anything unstated is dropped, and no guard existed — unlike the queued-call and validation-rule paths | `mdl/backend/modelsdk/workflow_read.go` (`boundaryEventsFromGen`, wired into the 6 gen types with `BoundaryEventsItems`); `mdl/executor/validate_workflow_rewrite.go` (`checkNoDroppedWorkflowConstructs`, called from `cmd_workflows_write.go`); `mdl/executor/cmd_workflows.go` (clause order) | **Read the stored side from the RAW unit, not the semantic model** — the reader is what was blind, and a guard sharing its blind spot cannot see what it protects; `GetRawUnit` also covers constructs no engine models yet (event sub-processes exist in `modelsdk/gen` but have no semantic type, reader or writer anywhere). Match the `$Type` by **substring** so all three timer variants and any later one are caught. Authorable constructs get the queue treatment (restate them and the rewrite proceeds); unauthorable ones refuse outright. **Fixing a reader can expose a describer bug**: with boundary events finally readable, `describe → exec` stopped parsing, because the call-microflow describer emitted `boundary event` BEFORE `outcomes` and the grammar requires the reverse — pre-existing on legacy, invisible while the default engine emitted neither. Always re-run a describe→re-exec round trip after widening a reader. Issue #948 | | A workflow that calls a microflow existing nowhere passes `mxcli check --references` with "All references valid" and is written by `exec` with exit 0; only native `mx check` catches it (**CE1613** "The selected microflow ... no longer exists"). The identical mistake inside a plain microflow body IS caught | `validateWithContext`'s `CreateWorkflowStmt` case called only `validateWorkflowParameterMappings`, whose own comment defers a target that is not in the project to "the missing-reference check" — **which was never written**. `validateFlowBodyReferences` (which does catch it) is wired only to `CreateMicroflowStmt`/`CreateNanoflowStmt`. `AlterWorkflowStmt` had **no case at all** and fell to `default: skip validation` | `mdl/executor/validate_workflow_refs.go` (`validateWorkflowReferences`, `validateWorkflowStatementRefs`, `validateAlterWorkflowRefs`); `mdl/executor/validate.go` (both switch cases, `scriptContext.workflows`, `collectDefinitions`/`collectSingle`/`allNames`); `mdl/executor/helpers.go` (`buildWorkflowQualifiedNames`); exec-side guards in `cmd_workflows_write.go` + `cmd_alter_workflow.go` | **The gap is never one reference kind** — probe them all before fixing. Here *every* reference in a workflow was unvalidated: call microflow, call workflow, user task page, targeting microflow, context entity, and the workflow's own module (checked for a microflow, not for a workflow — and `exec` auto-creates a module, so `check` was the only guard against a typo silently making one). **`check` and `exec` run different passes**: `check --references` runs `validateProgram`, `exec` does not, so a validator wired only into the switch leaves exec writing the broken model — the guard must ALSO be called from the statement handler, before `findOrCreateModule` (same shape as #833). Pass `nil` for the scriptContext there: exec applies statements one at a time, so an earlier statement's output is already in the project. **The real risk is false positives** — verify by diffing error counts against a baseline binary over every workflow script in `mdl-examples/`, with the referenced modules actually present, and exempt `System.*` (`isBuiltinModuleEntity`) or every built-in target is reported missing. Issue #943 | +| A microflow rewritten by `CREATE OR MODIFY MICROFLOW` is drawn with its **StartEvent stranded** — every activity where the script asked, the start left behind, joined to its own first activity by a long mostly-empty line across the canvas. Reproduces on every rewrite, and copying a working script's `@position` pattern does not help | The fix for the OPPOSITE report. #884 was a describe→exec round-trip MOVING a hand-placed start (145;200 → 100;200), fixed by carrying the stored position over on every rewrite; that then **pinned** the start of every rewritten flow, so it no longer followed activities the same script had just moved. Both reports are real and neither is answerable without asking where the stored value came from | `mdl/executor/cmd_microflows_start_position.go` (`authoredStartPosition`, `derivedStartPosition`, `startAnnotationLines`), `mdl/executor/cmd_microflows_create.go` (`storedStartPosition`), `mdl/executor/cmd_microflows_builder_graph.go` (StartEvent construction) | Carry the stored position over **only when it is not where the layout would have put it** — a start at `first.X − spacing` on `first.Y` is mxcli's own arithmetic handed back and carries no intent, so re-derive it; anywhere else, a person put it there, so keep it. Add `@start(x, y)` on the first statement (the `@merge` precedent — it positions the other node with no statement of its own) so the position can be *stated* rather than inferred, and have DESCRIBE emit it **only for a non-derived start**, which is what makes the round-trip exact without pinning every described flow. **`mx check` is not the oracle, and neither is a green build**: the Mendix model carries no geometry rules, so a stranded start is a valid document — 0 errors on mxbuild 11.13.0 before and after. Verify by reading the coordinates back off the stored document. **Emit from BOTH describers**: `formatMicroflowActivities` and `formatMicroflowActivitiesWithSourceMap` are near-duplicates, and the first cut of this fix patched only the second, so `describe microflow` silently dropped the line. Controls: reverting the narrowing reproduces the stranded start, reverting the builder arm reproduces "@start does nothing", reverting either describer reproduces the lossy round-trip. Repro `mdl-examples/bug-tests/951-microflow-start-event-position.mdl`. Issue #951 | diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows.md index 2224799cd..6b536b2a0 100644 --- a/.claude/skills/mendix/write-microflows.md +++ b/.claude/skills/mendix/write-microflows.md @@ -1108,6 +1108,7 @@ commit $Product; - Escape single quotes by doubling: `@annotation 'Don''t forget'` - `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default - DESCRIBE MICROFLOW shows `@` annotations before their activities +- `@start(x, y)` positions the **start event** and goes on the first statement, because the start has no statement of its own. Omit it and the start is derived — one spacing unit (160) left of the first activity, on its centre line — and a rewrite re-derives it so the start follows the activities when they move. A start that is not at the derived spot was placed by hand (in Studio Pro or with `@start`): it survives a rewrite that does not mention it, and DESCRIBE emits `@start` for it. An explicit `@start` overrides both (#951) ## Special Values diff --git a/CHANGELOG.md b/CHANGELOG.md index 364199f53..891986856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`@start(x, y)` positions a microflow's start event** (#951) — written on the first statement, the one the start flows into, because the start has no statement of its own; the same placement `@merge` uses for the other implicit node. It is optional: omitted, the start is derived one spacing unit left of the first activity on that activity's centre line, as before. `DESCRIBE` emits it only for a start that is *not* at that derived spot, so an ordinary description does not grow a line restating its own arithmetic while one carrying a hand-placed start still round-trips exactly. + - **Import mappings can reach a nested leaf without an entity per level** (#927) — `Attr = customer/contact/email` binds a value several levels below the object element it belongs to, which is the shape Studio Pro produces when you tick a nested leaf without ticking its parents: one entity, values pulled from several depths. Previously MDL had no way to write it, so every object level in a response became an entity whose only content was an association — one generated endpoint added 21 entities, almost all pass-throughs. Two shapes are refused rather than written, each measured on mxbuild 11.13 rather than assumed: an **export** mapping cannot collapse levels (**CE5015** — it has to produce the intermediate node; the same member in an import mapping builds at 0 errors, and the same export mapping with only top-level members builds at 0 errors), and an import member cannot cross a `0..*` element (**CE0256** "a schema element with wrong occurrence"). Both refusals name the build error they prevent. ### Fixed +- **A rewritten microflow no longer strands its start event** (#951) — `CREATE OR MODIFY MICROFLOW` moved every activity to where the script asked and left the start where the *previous* layout had put it, joined to its own first activity by a long, mostly-empty diagonal across the canvas. Measured on a real project: activities at `360;340`, start at `40;200`. + + The cause was the fix for the opposite report. #884 was a describe→exec round-trip *moving* a hand-placed start (`145;200` came back as `100;200`), fixed by carrying the stored position over on every rewrite — which then pinned the start of every rewritten flow. Both reports are real, and neither is answerable without asking where the stored value came from. A start sitting at the derived spot is mxcli's own arithmetic handed back, carries no intent, and is now re-derived so it follows the activities; a start anywhere else was placed by a person and still survives. `@start(x, y)` states the position outright and beats both, which is the other half of what #951 reported — before it there was no way to move a start once one had been preserved. + + Neither `mx check` nor a successful build detects this, in either direction: the Mendix model carries no geometry rules, so a stranded start is a valid document that builds and runs and is merely drawn wrong — 0 errors on mxbuild 11.13.0 before and after. + - **A `SHOW_PAGE` widget argument that is not the context object is refused instead of ignored** — `action: show_page Mod.Detail(Car: $Other)` inside a data view bound to `$Car` opened the page with **`$Car`**. mxcli stores a widget show-page action with an empty `ParameterMappings` array and lets Mendix infer the argument from the enclosing widget, which is deliberate and required (an explicit mapping is rejected as CE0115, #296) — but an argument naming anything else was then dropped on the floor by both engines. Nothing reported it: `mx check` gave **0 errors**, because an inferred mapping is a valid mapping, and `DESCRIBE` printed `(Car: $currentObject)`, so the description read as a diagnosis of a lost mapping rather than an accurate report of a model that never held one. The builder now refuses such an argument, and `mxcli check` flags it as **MDL-PAGEARG01** with no project needed. The guard fires only where it can prove the argument is discarded: `ALTER PAGE`'s `SET`/`INSERT` build an action against a stored page they never traverse, so the context object is unknown there and those statements are unaffected. Arguments that *do* name the context object — `$currentObject`, or the variable the enclosing data widget is bound to, which is the form the skills document — are unaffected. Found while verifying mxcli-formula1 §39, whose reported half (DESCRIBE omitting the inferred mapping) was already fixed. - **`DESCRIBE` no longer mis-reads a mapping that binds a nested leaf** (#927) — value elements were printed as the last segment of their JsonPath alone, so a project holding `(Object)|customer|name` described as `CustomerName = name`. That is a description of a model that does not exist, and re-executing mxcli's own output failed with `"name" is not a member of the JSON structure at (Object)`. Members are now rendered relative to the enclosing object element, on both engines and for both mapping kinds. Nothing was ever corrupted — the #882 guard refused the bad re-execution — but the description was wrong. diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index e8360a840..6d7908750 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -239,12 +239,13 @@ func init() { Register(SyntaxFeature{ Path: "microflow.layout", - Summary: "Canvas layout annotations — @position, @anchor, @curve, @caption, @color", + Summary: "Canvas layout annotations — @position, @start, @anchor, @curve, @caption, @color", Keywords: []string{ - "position", "anchor", "curve", "layout", "canvas", + "position", "start", "anchor", "curve", "layout", "canvas", "annotation", "caption", "color", "excluded", "bezier", }, Syntax: "@position(x, y) -- the activity's centre point\n" + + "@start(x, y) -- the start event, on the FIRST statement\n" + "@anchor(from: right, to: left) -- which SIDE each end of the outgoing flow attaches to\n" + "@curve(from: (40, -90), to: (-40, 90)) -- the flow's bezier control vectors\n" + "@merge(x, y) -- the implicit merge that closes a split\n" + @@ -254,9 +255,15 @@ func init() { "Mendix stores no waypoints — a flow's shape is two control vectors, each a\n" + "pixel offset from its end of the line. (0, 0) at both ends is straight.\n" + "@position on a split belongs to the SPLIT, so its end-if join has its own\n" + - "annotation. Container Size is still computed, not authorable.", + "annotation. Container Size is still computed, not authorable.\n\n" + + "@start and @merge position the two nodes that have no statement of their\n" + + "own, so each is written on the statement it belongs to. Omit @start and the\n" + + "start is placed one spacing unit left of the first activity, on its centre\n" + + "line — and a rewrite MOVES it to follow the activities. A start that is not\n" + + "at that derived spot was put there by hand: it survives a rewrite, and\n" + + "DESCRIBE emits @start for it so the description round-trips exactly.", Example: "create microflow MyModule.ACT_Flow ($In: String)\nreturns String as $Out\nbegin\n" + - " @position(200, 100)\n @anchor(from: bottom, to: top)\n" + + " @start(145, 100)\n @position(200, 100)\n @anchor(from: bottom, to: top)\n" + " @curve(from: (40, -90), to: (-40, 90))\n declare $Tmp String = $In;\n" + " @position(200, 300)\n declare $Out String = $Tmp;\n return $Out;\nend;", SeeAlso: []string{"microflow", "microflow.create"}, diff --git a/docs-site/src/appendixes/quick-reference.md b/docs-site/src/appendixes/quick-reference.md index a080c5712..0002239e1 100644 --- a/docs-site/src/appendixes/quick-reference.md +++ b/docs-site/src/appendixes/quick-reference.md @@ -161,6 +161,7 @@ AUTHENTICATION Basic, Session | Validation | `VALIDATION FEEDBACK $Entity/Attribute MESSAGE 'message';` | Requires attribute path + MESSAGE | | Log | `LOG INFO\|WARNING\|ERROR [NODE 'name'] 'message';` | | | Position | `@position(x, y)` | Canvas position (before activity) | +| Start event | `@start(x, y)` | Canvas position of the start, on the **first** statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951) | | Caption | `@caption 'text'` | Custom caption (before activity) | | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity | diff --git a/docs-site/src/language/microflow-structure.md b/docs-site/src/language/microflow-structure.md index 56709aacc..722ba35fa 100644 --- a/docs-site/src/language/microflow-structure.md +++ b/docs-site/src/language/microflow-structure.md @@ -138,6 +138,24 @@ Set the canvas position of the next activity: $Order = CREATE Sales.Order (Status = 'New'); ``` +### Start event + +The start event has no statement of its own, so `@start` goes on the **first** +statement — the one the start flows into: + +```sql +@start(145, 200) +@position(260, 200) +$Order = CREATE Sales.Order (Status = 'New'); +``` + +It is optional. Omitted, the start is placed one spacing unit left of the first +activity on that activity's centre line, and a rewrite re-derives it so the start +follows the activities when they move. A start that is *not* at that derived spot +was placed on purpose — in Studio Pro or with `@start` — so it survives a rewrite +that does not mention it, and `DESCRIBE` emits an `@start` line for it. An +explicit `@start` overrides both. + ### Caption Set a custom caption displayed on the activity in the canvas: diff --git a/docs-site/src/reference/microflow/create-microflow.md b/docs-site/src/reference/microflow/create-microflow.md index a7b64d3d8..19b7bef4d 100644 --- a/docs-site/src/reference/microflow/create-microflow.md +++ b/docs-site/src/reference/microflow/create-microflow.md @@ -148,11 +148,23 @@ Annotations are placed before an activity to control visual appearance in the mi ```sql @position(x, y) -- Canvas position +@start(x, y) -- Canvas position of the start event (first statement only) @caption 'text' -- Custom caption @color Green -- Background color @annotation 'text' -- Visual note attached to next activity ``` +`@start` positions the start event, which has no statement of its own, so it is +written on the first statement — the one the start flows into. It is optional: +omit it and the start is placed one spacing unit left of the first activity, on +that activity's centre line, and a later rewrite re-derives it so it follows the +activities when they move. + +A start that is *not* at that derived spot — one dragged somewhere in Studio Pro, +or written with `@start` — is treated as placed on purpose. It survives a rewrite +that does not mention it, and `DESCRIBE` emits an `@start` line for it so the +description reproduces the flow exactly. An explicit `@start` overrides both. + ## Parameters `module.Name` diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index d50c675be..9e4788a25 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -484,6 +484,7 @@ it is for pages. | Validation | `validation feedback $entity/attribute message 'message';` | Requires attribute path + MESSAGE | | Log | `log info\|warning\|error [node 'name'] 'message';` | | | Position | `@position(x, y)` | Canvas position (before activity) | +| Start event | `@start(x, y)` | Canvas position of the start, on the **first** statement. Omit it and the start is placed one spacing unit left of the first activity and MOVES with it on a rewrite; a start that is not at that derived spot is treated as hand-placed, survives a rewrite, and is emitted by DESCRIBE (#951) | | Caption | `@caption 'text'` | Custom caption (before activity) | | Color | `@color Green` | Background color (before activity) | | Annotation | `@annotation 'text'` | Visual note attached to next activity | diff --git a/mdl-examples/bug-tests/951-microflow-start-event-position.mdl b/mdl-examples/bug-tests/951-microflow-start-event-position.mdl new file mode 100644 index 000000000..eb2060ee9 --- /dev/null +++ b/mdl-examples/bug-tests/951-microflow-start-event-position.mdl @@ -0,0 +1,96 @@ +-- Repro: a full rewrite via CREATE OR MODIFY MICROFLOW stranded the StartEvent. +-- Every activity moved to where the script asked; the start stayed where the +-- PREVIOUS layout had put it, joined to its own first activity by a long +-- diagonal line across an otherwise empty canvas. Upstream #951. +-- +-- create ... activities at 200;200, 360;200, 520;200 -> start at 40;200 +-- rewrite ... activities at 360;340, 520;340, 680;340 -> start at 40;200 +-- ^ stranded +-- +-- The cause was the fix for the OPPOSITE report. #884: a Studio Pro flow whose +-- start sat at 145;200 came back at 100;200 on a describe -> exec round-trip, +-- because the start has no statement to annotate and the builder always derived +-- its position — (first activity X) minus one spacing unit. That was fixed by +-- carrying the stored position over on every rewrite, which then pinned the +-- start of every rewritten flow. Both reports are real and they pull opposite +-- ways, so neither is answerable without asking where the stored value CAME +-- from: +-- +-- * a start at the derived spot is mxcli's own arithmetic handed back. It +-- carries no intent, so it is re-derived and follows the activities. +-- * a start anywhere else was put there by a person. It survives the rewrite, +-- and DESCRIBE emits @start(x, y) for it so the description round-trips. +-- * @start(x, y) on the first statement states the position outright and beats +-- both — without it there was no way to move a start once one was preserved, +-- which is the other half of what #951 reported. +-- +-- `mx check` is NOT the oracle here, and neither is a successful build. The +-- Mendix model carries no geometry rules: a flow whose start sits 300px from +-- its first activity is a perfectly valid document that builds and runs, it is +-- just drawn wrong when opened. Measured on mxbuild 11.13.0 — 0 errors before +-- the fix and 0 errors after. Verify by reading the coordinates back. + +create module M951; + +-- 1. Fresh CREATE: the start is derived, one spacing unit (160) left of the +-- first activity, on its centre line. Expect StartEvent at 40;200. +create or modify microflow M951.Layout () +begin + @position(200, 200) + log 'one'; + @position(360, 200) + log 'two'; + @position(520, 200) + log 'three'; +end; + +-- 2. REWRITE moving every activity right and down. The start was derived, so it +-- follows. Expect StartEvent at 200;340 — NOT the 40;200 of #951. +create or modify microflow M951.Layout () +begin + @position(360, 340) + log 'one'; + @position(520, 340) + log 'two'; + @position(680, 340) + log 'three'; +end; + +-- 3. A hand-placed start: 145 is not derivable from 260 (260 - 160 = 100). +-- Expect StartEvent at 145;200. +create or modify microflow M951.Hand () +begin + @start(145, 200) + @position(260, 200) + log 'one'; + @position(420, 200) + log 'two'; +end; + +-- 4. DESCRIBE must emit `@start(145, 200)` for it — without that line the +-- description cannot reproduce the flow, which is #884. +describe microflow M951.Hand; + +-- 5. A rewrite that does NOT mention the start keeps it: 145;200 survives even +-- though the flow gained an activity. Expect StartEvent still at 145;200. +create or modify microflow M951.Hand () +begin + @position(260, 200) + log 'one'; + @position(420, 200) + log 'two'; + @position(580, 200) + log 'three'; +end; + +-- 6. @start moves a start that was previously preserved. Expect 100;200, which +-- is also the derived spot for a first activity at 260 — so from here on the +-- start is derived again and will follow the activities. +create or modify microflow M951.Hand () +begin + @start(100, 200) + @position(260, 200) + log 'one'; +end; + +describe microflow M951.Hand; diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index bd5eccf49..f780d63a0 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -269,6 +269,21 @@ type ActivityAnnotations struct { // unaddressable, and it routinely landed on top of a neighbouring activity. Merge *Position + // Start positions the StartEvent — the implicit node every flow begins at: + // @start(x, y), written on the FIRST statement, the one the start flows into. + // + // Same shape and same reason as Merge: the node has no statement of its own, + // so it is annotated on the statement it belongs to. Without it the start's + // placement was inferred rather than stated, and the two things an inference + // has to serve pull apart — a start a person dragged somewhere must survive a + // rebuild (#884), while one mxcli derived must follow the activities when + // they move (#951). An explicit position settles both by not guessing. + // + // DESCRIBE emits it only for a start that is not where the layout would have + // put it, so a described flow round-trips exactly without every description + // growing a line that just restates the arithmetic. (upstream #951) + Start *Position + // InvalidCurves holds the raw text of any @curve parameter whose coordinates // were not a whole-number (x, y) pair, so validation can refuse it rather // than silently straightening the edge. diff --git a/mdl/executor/cmd_microflows_builder_graph.go b/mdl/executor/cmd_microflows_builder_graph.go index b36b378eb..86dab9217 100644 --- a/mdl/executor/cmd_microflows_builder_graph.go +++ b/mdl/executor/cmd_microflows_builder_graph.go @@ -49,13 +49,23 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a } // Create StartEvent - Position is the CENTER point (RelativeMiddlePoint in Mendix) - // A position carried over from the microflow being replaced wins: the start - // has no statement to annotate, so a rebuild would otherwise move a - // hand-laid-out one to the derived spot. + // + // Three sources, weakest first. The derived spot above is the fallback; a + // hand-placed position carried over from the flow being replaced beats it + // (#884); an explicit @start(x, y) beats both, because it is the one source + // that states the position rather than inferring it (#951). startX, startY := fb.posX, fb.posY if fb.startPosition != nil { startX, startY = fb.startPosition.X, fb.startPosition.Y } + for _, stmt := range stmts { + ann := getStatementAnnotations(stmt) + if ann == nil || ann.Start == nil { + continue + } + startX, startY = ann.Start.X, ann.Start.Y + break + } startEvent := µflows.StartEvent{ BaseMicroflowObject: microflows.BaseMicroflowObject{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, diff --git a/mdl/executor/cmd_microflows_create.go b/mdl/executor/cmd_microflows_create.go index c389f0ad2..1dc0d0864 100644 --- a/mdl/executor/cmd_microflows_create.go +++ b/mdl/executor/cmd_microflows_create.go @@ -273,12 +273,14 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { restServices, _ := loadRestServices(ctx) builder := &flowBuilder{ - // Carry over the StartEvent position of the microflow being replaced. - // The start has no MDL statement to annotate and DESCRIBE cannot emit - // it, so a rebuild would otherwise move a hand-laid-out one to the - // derived spot — a Studio Pro flow's 145;200 became 100;200 on a - // describe→exec round-trip, the only coordinate in it that did not - // survive. Preserved the way the folder and allowed roles already are. + // Carry over a HAND-PLACED StartEvent position from the microflow being + // replaced, the way the folder and allowed roles already are: a Studio + // Pro flow's 145;200 became 100;200 on a describe→exec round-trip, the + // only coordinate in it that did not survive (#884). A start sitting + // where mxcli's own layout would have put it is not carried over — that + // pinned the start of every rewritten flow, stranding it across the + // canvas from activities the same script had just moved (#951). An + // explicit @start(x, y) on the first statement overrides both. startPosition: storedStartPosition(ctx, existingID), posX: 200, posY: 200, @@ -331,22 +333,21 @@ func execCreateMicroflow(ctx *ExecContext, s *ast.CreateMicroflowStmt) error { } // storedStartPosition reads the StartEvent position off the microflow being -// replaced, or nil for a fresh CREATE (where the position is derived from the -// first annotated activity). Best-effort: a backend that cannot read the flow -// yields the derived placement rather than failing the statement. +// replaced, when a person put it there rather than mxcli's own layout — see +// authoredStartPosition for how the two are told apart, and why carrying the +// position over unconditionally pinned the start of every rewritten flow (#951). +// +// Nil for a fresh CREATE, and nil for a start sitting where the layout would +// have put it anyway; both derive from the new first activity. Best-effort: a +// backend that cannot read the flow yields the derived placement rather than +// failing the statement. func storedStartPosition(ctx *ExecContext, existingID model.ID) *model.Point { if existingID == "" || ctx.Backend == nil { return nil } mf, err := ctx.Backend.GetMicroflow(existingID) - if err != nil || mf == nil || mf.ObjectCollection == nil { + if err != nil || mf == nil { return nil } - for _, o := range mf.ObjectCollection.Objects { - if se, ok := o.(*microflows.StartEvent); ok { - p := se.GetPosition() - return &p - } - } - return nil + return authoredStartPosition(mf.ObjectCollection) } diff --git a/mdl/executor/cmd_microflows_show.go b/mdl/executor/cmd_microflows_show.go index ec1dad140..a6a8ce282 100644 --- a/mdl/executor/cmd_microflows_show.go +++ b/mdl/executor/cmd_microflows_show.go @@ -783,6 +783,8 @@ func formatMicroflowActivities( // Build annotation map for @annotation emission annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection) + lines = append(lines, startAnnotationLines(mf.ObjectCollection)...) + // flowsByOrigin / flowsByDest are threaded into traverseFlow so @anchor // emission is per-call — no package-level globals, safe under concurrent // describe (e.g. captureDescribeParallel). @@ -1014,6 +1016,8 @@ func formatMicroflowActivitiesWithSourceMap( // Build annotation map for @annotation emission annotationsByTarget := buildAnnotationsByTarget(mf.ObjectCollection) + lines = append(lines, startAnnotationLines(mf.ObjectCollection)...) + traverseFlow(ctx, startID, activityMap, flowsByOrigin, flowsByDest, splitMergeMap, visited, entityNames, microflowNames, &lines, 0, sourceMap, headerLineCount, annotationsByTarget) return lines diff --git a/mdl/executor/cmd_microflows_start_position.go b/mdl/executor/cmd_microflows_start_position.go new file mode 100644 index 000000000..3127fece1 --- /dev/null +++ b/mdl/executor/cmd_microflows_start_position.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package executor - StartEvent placement across a microflow rewrite. +package executor + +import ( + "fmt" + + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// derivedStartPosition returns where mxcli's own layout puts the StartEvent of a +// stored flow: one spacing unit left of the object the start flows into, on that +// object's centre line. This is buildFlowGraph's rule read backwards, and the two +// are pinned together by TestDerivedStartPositionMatchesTheBuilder. +// +// Reports false when the flow has no start, or the start leads nowhere — with no +// first object there is nothing to derive from, and nothing can be concluded +// about where the stored coordinates came from. +func derivedStartPosition(oc *microflows.MicroflowObjectCollection) (model.Point, bool) { + if oc == nil { + return model.Point{}, false + } + var startID model.ID + byID := make(map[model.ID]microflows.MicroflowObject, len(oc.Objects)) + for _, o := range oc.Objects { + byID[o.GetID()] = o + if _, ok := o.(*microflows.StartEvent); ok { + startID = o.GetID() + } + } + if startID == "" { + return model.Point{}, false + } + for _, f := range oc.Flows { + if f.OriginID != startID { + continue + } + first, ok := byID[f.DestinationID] + if !ok { + continue + } + p := first.GetPosition() + return model.Point{X: p.X - HorizontalSpacing, Y: p.Y}, true + } + return model.Point{}, false +} + +// authoredStartPosition returns the StartEvent position of a stored flow when +// those coordinates say something mxcli's own layout would not have produced — +// that is, when a person put the start there. +// +// The two reports this arbitrates are the same bug seen from opposite sides, and +// neither is answerable without asking where the stored value came from: +// +// - #884: a Studio Pro flow whose start sat at 145;200 came back at 100;200 +// on a describe→exec round-trip. 145 is not derivable from anything in the +// description, so a rebuild has to be told; carrying the stored value over +// is how it is told. +// - #951: carrying it over UNCONDITIONALLY then pinned the start of every +// rewritten flow. Move the activities and the start stays behind — measured +// at 40;200 while its own first activity had moved to 360;340, joined by a +// long diagonal line across an otherwise empty canvas. +// +// A start at the derived spot is mxcli's own arithmetic handed back, so it +// carries no intent and is re-derived. A start anywhere else was placed by +// someone and is kept. A person who placed one exactly where the layout would +// have is indistinguishable from the layout — and re-deriving gives the same +// point for an unchanged flow, so the ambiguity costs nothing. +// +// Returns nil when there is no start, when nothing can be derived, or when the +// stored start is at the derived spot. +func authoredStartPosition(oc *microflows.MicroflowObjectCollection) *model.Point { + derived, ok := derivedStartPosition(oc) + if !ok { + return nil + } + for _, o := range oc.Objects { + se, ok := o.(*microflows.StartEvent) + if !ok { + continue + } + p := se.GetPosition() + if p == derived { + return nil + } + return &p + } + return nil +} + +// startAnnotationLines returns the `@start(x, y)` line a description needs, or +// nothing at all. +// +// Emitted only for a start that is not where the layout would have put it, so an +// ordinary description does not grow a line restating its own arithmetic — and, +// more than cosmetics, so a described flow does not come back with its start +// pinned to a spot the next rewrite has to strand it at (#951). A start the +// arithmetic cannot reconstruct is emitted, which is what makes a described flow +// round-trip exactly (#884). +// +// The line goes ahead of the leading statement's own annotations, because the +// statement the start flows into is the one it belongs to — the same placement +// @merge uses for the other node with no statement of its own. +// +// Shared by both describers: formatMicroflowActivities and +// formatMicroflowActivitiesWithSourceMap are near-duplicates, and a describer +// that emits it while its twin does not makes the round-trip depend on which +// command the author happened to run. +func startAnnotationLines(oc *microflows.MicroflowObjectCollection) []string { + p := authoredStartPosition(oc) + if p == nil { + return nil + } + return []string{fmt.Sprintf("@start(%d, %d)", p.X, p.Y)} +} diff --git a/mdl/executor/cmd_misc.go b/mdl/executor/cmd_misc.go index 3022d4fd2..2b0adf07a 100644 --- a/mdl/executor/cmd_misc.go +++ b/mdl/executor/cmd_misc.go @@ -152,6 +152,7 @@ Microflows: @caption 'text' -- Custom caption for activity @color Green -- Background color for activity @position(100, 200) -- Canvas position for activity + @start(60, 200) -- Canvas position for the start event return $ReturnVar; end; / diff --git a/mdl/executor/start_event_position_test.go b/mdl/executor/start_event_position_test.go index bfa7b95f6..cf481a19a 100644 --- a/mdl/executor/start_event_position_test.go +++ b/mdl/executor/start_event_position_test.go @@ -1,18 +1,30 @@ // SPDX-License-Identifier: Apache-2.0 -// A microflow's StartEvent position is not expressible in MDL and is not -// captured by DESCRIBE, so a describe→exec round-trip used to move it: a -// Studio-Pro-authored flow whose start sat at 145;200 came back at 100;200, -// because the builder derives the start as (first annotated activity X − -// spacing) and 145 is not derivable from 260. +// Where a microflow's StartEvent lands across a rewrite, and why it takes three +// sources to get right. // -// Every other coordinate in that flow round-trips exactly, so this was the one -// piece of a hand-laid-out microflow mxcli could not preserve. It is preserved -// the way the folder and the allowed module roles already are on CREATE OR -// MODIFY: read off the stored document and carried over. +// The start has no statement of its own, so the builder derives its position: +// one spacing unit left of the first activity, on that activity's centre line. +// Two reports pull that derivation in opposite directions. +// +// - #884: a Studio Pro flow whose start sat at 145;200 came back at 100;200 on +// a describe→exec round-trip — 145 is not derivable from 260, and every +// other coordinate in the flow survived. Carrying the stored value over +// fixed it. +// - #951: carrying it over UNCONDITIONALLY pinned the start of every rewritten +// flow. Measured on a real project: activities moved to 360;340 by the very +// script doing the rewrite, start left behind at 40;200. +// +// Neither is answerable without asking where the stored value came from, which +// authoredStartPosition does — a start at the derived spot is mxcli's own +// arithmetic handed back and carries no intent; a start anywhere else was placed +// by a person. On top of that, @start(x, y) states the position outright, which +// is what DESCRIBE emits for a start that arithmetic cannot reconstruct. package executor import ( + "context" + "strings" "testing" "github.com/mendixlabs/mxcli/mdl/ast" @@ -29,7 +41,10 @@ func startEventOf(objects []microflows.MicroflowObject) *microflows.StartEvent { return nil } -func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent { +// buildFlowWithStart builds a one-activity flow whose activity is annotated at +// 260;200, so the derived start is 100;200. start is the carried-over position +// (nil for a fresh CREATE); explicitStart is an @start(x, y) on the statement. +func buildFlowWithStart(t *testing.T, start *model.Point, explicitStart *ast.Position) *microflows.StartEvent { t.Helper() fb := &flowBuilder{ posX: 100, posY: 200, baseY: 200, spacing: HorizontalSpacing, @@ -39,8 +54,11 @@ func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent startPosition: start, } stmt := &ast.LogStmt{ - Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, - Annotations: &ast.ActivityAnnotations{Position: &ast.Position{X: 260, Y: 200}}, + Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "x"}, + Annotations: &ast.ActivityAnnotations{ + Position: &ast.Position{X: 260, Y: 200}, + Start: explicitStart, + }, } fb.buildFlowGraph([]ast.MicroflowStatement{stmt}, nil) se := startEventOf(fb.objects) @@ -52,7 +70,7 @@ func buildFlowWithStart(t *testing.T, start *model.Point) *microflows.StartEvent // The stored position wins over the derived one. func TestStartEvent_PreservesStoredPosition(t *testing.T) { - se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}) + se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}, nil) if se.Position.X != 145 || se.Position.Y != 200 { t.Errorf("StartEvent at %d;%d, want 145;200 — a hand-laid-out start must survive a rebuild", se.Position.X, se.Position.Y) @@ -62,8 +80,182 @@ func TestStartEvent_PreservesStoredPosition(t *testing.T) { // With nothing stored (a fresh CREATE) the derived placement is unchanged: // one spacing unit left of the first annotated activity. func TestStartEvent_DerivesWhenNothingStored(t *testing.T) { - se := buildFlowWithStart(t, nil) + se := buildFlowWithStart(t, nil, nil) if want := 260 - HorizontalSpacing; se.Position.X != want { t.Errorf("StartEvent at %d, want %d (derived)", se.Position.X, want) } } + +// @start(x, y) beats a carried-over position: it states where the start goes, +// where the carry-over only infers it. Without this there is no way to move a +// start once one has been preserved. (#951) +func TestStartEvent_ExplicitAnnotationBeatsTheCarriedOverPosition(t *testing.T) { + se := buildFlowWithStart(t, &model.Point{X: 145, Y: 200}, &ast.Position{X: 500, Y: 620}) + if se.Position.X != 500 || se.Position.Y != 620 { + t.Errorf("StartEvent at %d;%d, want 500;620 — @start must win over the carry-over", + se.Position.X, se.Position.Y) + } +} + +// --- Which stored positions are carried over at all (#951) --- + +// oneActivityFlow is a stored flow: start → activity → end, with the start and +// the activity where the caller puts them. +func oneActivityFlow(start, activity model.Point) *microflows.MicroflowObjectCollection { + se := µflows.StartEvent{BaseMicroflowObject: mkObj("start")} + se.Position = start + act := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("act")}, + } + act.Position = activity + end := µflows.EndEvent{BaseMicroflowObject: mkObj("end")} + return µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se, act, end}, + Flows: []*microflows.SequenceFlow{mkFlow("start", "act"), mkFlow("act", "end")}, + } +} + +func TestAuthoredStartPosition(t *testing.T) { + cases := []struct { + name string + start model.Point + activity model.Point + want *model.Point + }{{ + // #884: Studio Pro put the start at 145 where the layout would put 100. + name: "hand-placed start is carried over", + start: model.Point{X: 145, Y: 200}, + activity: model.Point{X: 260, Y: 200}, + want: &model.Point{X: 145, Y: 200}, + }, { + // #951: mxcli's own arithmetic, handed back. Carrying it over is what + // stranded the start when the next rewrite moved the activities. + name: "start at the derived spot is not carried over", + start: model.Point{X: 40, Y: 200}, + activity: model.Point{X: 200, Y: 200}, + want: nil, + }, { + // Same X, different centre line — a start left on the old baseline + // while the flow moved down is exactly the diagonal from #951. + name: "start off the activity centre line is carried over", + start: model.Point{X: 200, Y: 200}, + activity: model.Point{X: 360, Y: 340}, + want: &model.Point{X: 200, Y: 200}, + }} + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := authoredStartPosition(oneActivityFlow(tc.start, tc.activity)) + switch { + case tc.want == nil && got != nil: + t.Fatalf("carried over %d;%d, want nothing carried over — the rebuild must "+ + "re-derive it from wherever the activities now are", got.X, got.Y) + case tc.want != nil && got == nil: + t.Fatalf("carried over nothing, want %d;%d — a position nobody can "+ + "reconstruct by arithmetic must survive the rebuild", tc.want.X, tc.want.Y) + case tc.want != nil && *got != *tc.want: + t.Fatalf("carried over %d;%d, want %d;%d", got.X, got.Y, tc.want.X, tc.want.Y) + } + }) + } +} + +// A flow the derivation cannot be run against yields no carry-over rather than a +// wrong one: with no first object there is nothing to compare the start to, so +// its provenance is unknown and inventing an answer either pins a derived start +// or discards a hand-placed one. +func TestAuthoredStartPosition_UnderivableFlowsCarryNothing(t *testing.T) { + se := µflows.StartEvent{BaseMicroflowObject: mkObj("start")} + se.Position = model.Point{X: 145, Y: 200} + + for _, tc := range []struct { + name string + oc *microflows.MicroflowObjectCollection + }{ + {"nil collection", nil}, + {"no start event", µflows.MicroflowObjectCollection{}}, + {"start flows nowhere", µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se}, + }}, + {"start flows to an object that is not in the collection", µflows.MicroflowObjectCollection{ + Objects: []microflows.MicroflowObject{se}, + Flows: []*microflows.SequenceFlow{mkFlow("start", "missing")}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := authoredStartPosition(tc.oc); got != nil { + t.Errorf("carried over %d;%d, want nothing", got.X, got.Y) + } + }) + } +} + +// derivedStartPosition is buildFlowGraph's placement rule read backwards, so the +// two have to agree — a builder that changed its spacing or its centre line +// while the reader did not would classify every generated start as hand-placed +// and pin it, which is #951 again with a longer fuse. +// +// Asserted by running the builder and the reader over the same flow rather than +// by restating the arithmetic, which would pass against either one being wrong. +func TestDerivedStartPositionMatchesTheBuilder(t *testing.T) { + built := buildFlowWithStart(t, nil, nil) + + act := µflows.ActionActivity{ + BaseActivity: microflows.BaseActivity{BaseMicroflowObject: mkObj("act")}, + } + act.Position = model.Point{X: 260, Y: 200} + derived, ok := derivedStartPosition(oneActivityFlow(built.Position, act.Position)) + if !ok { + t.Fatal("derivedStartPosition found nothing to derive from") + } + if derived != built.Position { + t.Errorf("the reader derives %d;%d where the builder places %d;%d — "+ + "the two placements have drifted apart", + derived.X, derived.Y, built.Position.X, built.Position.Y) + } +} + +// --- What DESCRIBE emits (#951) --- + +// Both describers, because they are near-duplicates: DESCRIBE MICROFLOW goes +// through formatMicroflowActivities and the ELK/source-map path through +// formatMicroflowActivitiesWithSourceMap. Testing one leaves the round-trip +// depending on which command the author happened to run — the first cut of this +// fix emitted from the source-map one only, and DESCRIBE dropped the line. +func TestDescribeEmitsStartAnnotationOnlyWhenItCarriesInformation(t *testing.T) { + ctx := newTestExecutor().newExecContext(context.Background()) + + describers := map[string]func(*microflows.Microflow) []string{ + "formatMicroflowActivities": func(mf *microflows.Microflow) []string { + return formatMicroflowActivities(ctx, mf, nil, nil) + }, + "formatMicroflowActivitiesWithSourceMap": func(mf *microflows.Microflow) []string { + return formatMicroflowActivitiesWithSourceMap(ctx, mf, nil, nil, nil, 0) + }, + } + + for name, describe := range describers { + t.Run(name+"/hand-placed start is emitted", func(t *testing.T) { + mf := µflows.Microflow{ + ObjectCollection: oneActivityFlow(model.Point{X: 145, Y: 200}, model.Point{X: 260, Y: 200}), + } + out := strings.Join(describe(mf), "\n") + if !strings.Contains(out, "@start(145, 200)") { + t.Errorf("no @start line — a start at 145 cannot be reconstructed from a "+ + "description that does not mention it:\n%s", out) + } + }) + + t.Run(name+"/derived start is not emitted", func(t *testing.T) { + mf := µflows.Microflow{ + ObjectCollection: oneActivityFlow(model.Point{X: 100, Y: 200}, model.Point{X: 260, Y: 200}), + } + out := strings.Join(describe(mf), "\n") + if strings.Contains(out, "@start(") { + t.Errorf("@start emitted for a start the layout would place there anyway — "+ + "every description would grow a line restating its own arithmetic, and "+ + "pin the start of every flow it round-trips:\n%s", out) + } + }) + } +} diff --git a/mdl/executor/validate_microflow.go b/mdl/executor/validate_microflow.go index 8f5c3a2d2..a4f65ecb6 100644 --- a/mdl/executor/validate_microflow.go +++ b/mdl/executor/validate_microflow.go @@ -1390,6 +1390,7 @@ var knownActivityAnnotations = map[string]bool{ "anchor": true, "curve": true, "merge": true, + "start": true, } // checkUnknownAnnotations rejects an @annotation name the visitor does not @@ -1419,8 +1420,8 @@ func (v *microflowValidator) checkUnknownAnnotations(s ast.MicroflowStatement) { v.addViolation("MDL059", linter.SeverityError, fmt.Sprintf("unknown annotation `@%s` — it parses but does nothing, so whatever it was "+ "meant to express is silently lost", name), - fmt.Sprintf("mxcli implements @position(x, y), @caption, @color, @annotation, @excluded and "+ - "@anchor on a microflow statement. If `@%s` is a typo of one of those, correct it; "+ - "container size and edge geometry are not authorable (upstream #884).", name)) + fmt.Sprintf("mxcli implements @position(x, y), @start(x, y), @caption, @color, @annotation, "+ + "@excluded, @anchor, @curve and @merge on a microflow statement. If `@%s` is a typo of "+ + "one of those, correct it; container size is not authorable (upstream #884).", name)) } } diff --git a/mdl/visitor/visitor_microflow_statements.go b/mdl/visitor/visitor_microflow_statements.go index 2c73160e0..cfc03059c 100644 --- a/mdl/visitor/visitor_microflow_statements.go +++ b/mdl/visitor/visitor_microflow_statements.go @@ -326,6 +326,23 @@ func extractMicroflowAnnotations(annotations []parser.IAnnotationContext) *ast.A } seenActivityMetadata = true + case "start": + // @start(x, y) — the StartEvent, the implicit node the flow begins + // at. Written on the FIRST statement, the one the start flows into; + // same positional shape and same reason as @merge, which positions + // the other node that has no statement of its own. (#951) + if params := ann.AnnotationParams(); params != nil { + allParams := params.(*parser.AnnotationParamsContext).AllAnnotationParam() + if len(allParams) >= 2 { + result.Start = &ast.Position{ + X: parseAnnotationParamInt(allParams[0]), + Y: parseAnnotationParamInt(allParams[1]), + } + hasAny = true + } + } + seenActivityMetadata = true + case "curve": // @curve(from: (40, -90), to: (-40, 90)) — the bezier control // vectors of the flow LEAVING this statement. Needed no grammar diff --git a/mdl/visitor/visitor_start_annotation_test.go b/mdl/visitor/visitor_start_annotation_test.go new file mode 100644 index 000000000..3014c772b --- /dev/null +++ b/mdl/visitor/visitor_start_annotation_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Tests for @start(x, y) — the StartEvent's position, written on the first +// statement because the start has no statement of its own. Same shape and same +// reason as @merge, which positions the other implicit node. +// +// The builder-side tests construct ast.ActivityAnnotations directly, so they +// pass whether or not @start survives the grammar and the visitor. This is the +// half that proves an author can actually write it. (upstream #951) +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +func TestStartAnnotation_ReachesTheAST(t *testing.T) { + stmt := firstStatement(t, "@start(145, 200)\n@position(260, 200)\nlog info node 'App' 'hi';") + + log, ok := stmt.(*ast.LogStmt) + if !ok { + t.Fatalf("expected LogStmt, got %T", stmt) + } + if log.Annotations == nil || log.Annotations.Start == nil { + t.Fatal("@start did not reach the AST — it parses as a bare annotation name, " + + "so a missing visitor arm loses it in silence") + } + if got := *log.Annotations.Start; got.X != 145 || got.Y != 200 { + t.Errorf("@start parsed as %d;%d, want 145;200", got.X, got.Y) + } + // @start must not consume the statement's own @position. + if log.Annotations.Position == nil { + t.Fatal("@position was lost") + } + if got := *log.Annotations.Position; got.X != 260 || got.Y != 200 { + t.Errorf("@position parsed as %d;%d, want 260;200", got.X, got.Y) + } +} + +// Negative coordinates: Studio Pro's canvas origin is not a corner, so a start +// dragged up and left of the first activity is ordinary, not malformed. +func TestStartAnnotation_AcceptsNegativeCoordinates(t *testing.T) { + stmt := firstStatement(t, "@start(-40, -120)\nlog info node 'App' 'hi';") + log := stmt.(*ast.LogStmt) + if log.Annotations == nil || log.Annotations.Start == nil { + t.Fatal("@start did not reach the AST") + } + if got := *log.Annotations.Start; got.X != -40 || got.Y != -120 { + t.Errorf("@start parsed as %d;%d, want -40;-120", got.X, got.Y) + } +} + +// A known annotation name must not land in UnknownNames, or MDL059 rejects the +// very statement DESCRIBE just wrote. +func TestStartAnnotation_IsNotReportedUnknown(t *testing.T) { + stmt := firstStatement(t, "@start(145, 200)\nlog info node 'App' 'hi';") + log := stmt.(*ast.LogStmt) + if n := log.Annotations.UnknownNames; len(n) > 0 { + t.Errorf("@start recorded as unknown %v — MDL059 would reject it", n) + } +} From 33ec302e0a15bb518419ab452dbddb6910d061c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:17:28 +0000 Subject: [PATCH 3/7] fix(microflows): stop SPLIT TYPE stacking its merge and what follows (#953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in addStructuredInheritanceSplit, both invisible below Studio Pro. 1. The builder ended with `fb.posX = mergeX` — the merge's own centre — so whatever followed END SPLIT was drawn on top of the merge, joined by a zero-length sequence flow. addEnumSplit steps to mergeX + HorizontalSpacing/2 and addIfStatement to mergeX + MergeSize + HorizontalSpacing/2; three builders, three conventions, and the type split's was zero. The IF convention is the one adopted here: the spacing constants are centre-to-centre and tuned for a 40px edge gap, and clearing a MergeSize-wide merge before a full-width activity needs MergeSize + half a pitch. CASE's HorizontalSpacing/2 leaves a following activity's left edge exactly touching the merge (measured: merge 890, activity 970, both edges at 910). That is a lesser, pre-existing nit and is deliberately left alone — changing it would re-lay-out every enum split ever written. 2. Branch width came from every branch body concatenated into one list and measured as a single left-to-right run. Branches are stacked vertically, so only the widest matters; summing them slid the merge right by an activity-plus-spacing per extra branch. layout.go's own measureInheritanceSplitStatement always took the max, so the builder disagreed with its own measurer. appendInheritanceBodies — the helper whose name invited this — is replaced by inheritanceBranchBodies, which keeps the branches apart. Measured with mxcli describe (which prints the stored coordinates, so no modeler is needed), mxbuild 11.13.0: merge after END SPLIT branches three branches before 1480 1480 (stacked) 720 after 920 1040 720 four branches before 1760 1760 (stacked) 720 after 920 1040 720 <- no drift uneven branches before 1760 1760 (stacked) 720, 880 after 1200 1320 720, 880 The control is the enum split with the same graph shape: byte-identical before and after (merge 890, end 970), as are IF/ELSE and every other example. mx check reports 0 errors on both the broken and the fixed project — the model is valid either way, which is why this shipped. describe -> exec of the fixed microflow reports "Unchanged", so the geometry round-trips. The split builders had no positional coverage at all before this; the new tests assert coordinates and pin the enum split so a later attempt to unify the two cannot move it silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/fix-issue.md | 1 + .../953-split-type-merge-overlap.mdl | 114 +++++++++++ .../cmd_microflows_builder_actions.go | 44 ++++- ..._microflows_builder_split_geometry_test.go | 179 ++++++++++++++++++ 4 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 mdl-examples/bug-tests/953-split-type-merge-overlap.mdl create mode 100644 mdl/executor/cmd_microflows_builder_split_geometry_test.go diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 130661325..329f54cbc 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -610,3 +610,4 @@ extracting `OffsetExpression`/`LimitExpression`. | A workflow that calls a microflow existing nowhere passes `mxcli check --references` with "All references valid" and is written by `exec` with exit 0; only native `mx check` catches it (**CE1613** "The selected microflow ... no longer exists"). The identical mistake inside a plain microflow body IS caught | `validateWithContext`'s `CreateWorkflowStmt` case called only `validateWorkflowParameterMappings`, whose own comment defers a target that is not in the project to "the missing-reference check" — **which was never written**. `validateFlowBodyReferences` (which does catch it) is wired only to `CreateMicroflowStmt`/`CreateNanoflowStmt`. `AlterWorkflowStmt` had **no case at all** and fell to `default: skip validation` | `mdl/executor/validate_workflow_refs.go` (`validateWorkflowReferences`, `validateWorkflowStatementRefs`, `validateAlterWorkflowRefs`); `mdl/executor/validate.go` (both switch cases, `scriptContext.workflows`, `collectDefinitions`/`collectSingle`/`allNames`); `mdl/executor/helpers.go` (`buildWorkflowQualifiedNames`); exec-side guards in `cmd_workflows_write.go` + `cmd_alter_workflow.go` | **The gap is never one reference kind** — probe them all before fixing. Here *every* reference in a workflow was unvalidated: call microflow, call workflow, user task page, targeting microflow, context entity, and the workflow's own module (checked for a microflow, not for a workflow — and `exec` auto-creates a module, so `check` was the only guard against a typo silently making one). **`check` and `exec` run different passes**: `check --references` runs `validateProgram`, `exec` does not, so a validator wired only into the switch leaves exec writing the broken model — the guard must ALSO be called from the statement handler, before `findOrCreateModule` (same shape as #833). Pass `nil` for the scriptContext there: exec applies statements one at a time, so an earlier statement's output is already in the project. **The real risk is false positives** — verify by diffing error counts against a baseline binary over every workflow script in `mdl-examples/`, with the referenced modules actually present, and exempt `System.*` (`isBuiltinModuleEntity`) or every built-in target is reported missing. Issue #943 | | `CREATE OR MODIFY ODATA CLIENT` reports `Unchanged OData client` after the contract file was refreshed from the running backend — `SHOW CONTRACT ENTITIES` keeps listing the old entity types, and the `CREATE OR MODIFY EXTERNAL ENTITIES` that follows imports the old shape with no warning. `DROP ODATA CLIENT` + recreate is the only way out, and it invalidates the client ID the existing external entities point at | The modify branch updated every property **except the cached contract**. Only the create path fetched `$metadata`; `svc.Metadata` / `svc.MetadataHash` kept the snapshot taken when the client was first created. The same branch also stored `stmt.MetadataUrl` raw, skipping the `NormalizeURL` the create path applies, so a relative `./contracts/x.xml` ended up as a URL neither Studio Pro nor `fetchODataMetadata` can open | `mdl/executor/cmd_odata.go` (`refreshCachedMetadata`, `normalizeMetadataURL`, `contractSummary`, called from the `stmt.CreateOrModify` branch of `createODataClient`); test `mdl/executor/cmd_odata_metadata_refresh_test.go`; repro `mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl` | **Refresh from the *effective* URL, not the statement's** — a `CREATE OR MODIFY` that omits `MetadataUrl` still has to converge the cache, or the idiomatic re-run silently keeps the stale contract. **A failed fetch must keep what is cached** (warning, not error): losing a contract that was merely unreachable is worse than serving a stale one, and there is nothing to fall back on. **Leave `svc` untouched when the hash matches**, or ADR-0008 elision breaks and every re-run churns the document — the unchanged-contract control is what proves this, and it passes both before and after the fix, so it is the half of the test that keeps the other half honest. `ALTER ODATA CLIENT SET MetadataUrl` has the same staleness and is deliberately **not** fixed here: it carries no design-time credentials, so a blind re-fetch of an authenticated URL would warn where it used to be silent. `mx check` is no oracle for any of this — the stale project builds with 0 errors, because a client caching an old contract is perfectly valid | | A microflow rewritten by `CREATE OR MODIFY MICROFLOW` is drawn with its **StartEvent stranded** — every activity where the script asked, the start left behind, joined to its own first activity by a long mostly-empty line across the canvas. Reproduces on every rewrite, and copying a working script's `@position` pattern does not help | The fix for the OPPOSITE report. #884 was a describe→exec round-trip MOVING a hand-placed start (145;200 → 100;200), fixed by carrying the stored position over on every rewrite; that then **pinned** the start of every rewritten flow, so it no longer followed activities the same script had just moved. Both reports are real and neither is answerable without asking where the stored value came from | `mdl/executor/cmd_microflows_start_position.go` (`authoredStartPosition`, `derivedStartPosition`, `startAnnotationLines`), `mdl/executor/cmd_microflows_create.go` (`storedStartPosition`), `mdl/executor/cmd_microflows_builder_graph.go` (StartEvent construction) | Carry the stored position over **only when it is not where the layout would have put it** — a start at `first.X − spacing` on `first.Y` is mxcli's own arithmetic handed back and carries no intent, so re-derive it; anywhere else, a person put it there, so keep it. Add `@start(x, y)` on the first statement (the `@merge` precedent — it positions the other node with no statement of its own) so the position can be *stated* rather than inferred, and have DESCRIBE emit it **only for a non-derived start**, which is what makes the round-trip exact without pinning every described flow. **`mx check` is not the oracle, and neither is a green build**: the Mendix model carries no geometry rules, so a stranded start is a valid document — 0 errors on mxbuild 11.13.0 before and after. Verify by reading the coordinates back off the stored document. **Emit from BOTH describers**: `formatMicroflowActivities` and `formatMicroflowActivitiesWithSourceMap` are near-duplicates, and the first cut of this fix patched only the second, so `describe microflow` silently dropped the line. Controls: reverting the narrowing reproduces the stranded start, reverting the builder arm reproduces "@start does nothing", reverting either describer reproduces the lossy round-trip. Repro `mdl-examples/bug-tests/951-microflow-start-event-position.mdl`. Issue #951 | +| `SPLIT TYPE … END SPLIT` followed by any statement draws that statement **on top of the merge** in Studio Pro, joined by a zero-length sequence flow, and the merge itself sits far to the right of the branches it joins (branches at x=720, merge at x=1480). `CASE` and `IF/ELSE` with the same graph shape lay out correctly. `mx check` reports 0 errors either way | Two defects in `addStructuredInheritanceSplit`. (1) It ended with `fb.posX = mergeX` — the merge's own centre — where `addEnumSplit` steps to `mergeX + HorizontalSpacing/2` and `addIfStatement` to `mergeX + MergeSize + HorizontalSpacing/2`; three builders, three conventions, and the type split's was zero. (2) Branch width came from `measureStatements(appendInheritanceBodies(s))`, which concatenated every branch body into ONE list and measured it as a single left-to-right run — so the merge slid right by an activity-plus-spacing per **extra branch**, not per branch *width*. `layout.go`'s own `measureInheritanceSplitStatement` always took the max, so the builder disagreed with its measurer | `mdl/executor/cmd_microflows_builder_actions.go` (`addStructuredInheritanceSplit`; `appendInheritanceBodies` → `inheritanceBranchBodies`); tests `mdl/executor/cmd_microflows_builder_split_geometry_test.go`; repro `mdl-examples/bug-tests/953-split-type-merge-overlap.mdl` | **Geometry is invisible to every automatic check below Studio Pro** — the model is valid, so `mx check` passes, the build passes, and `describe` output looks right until you read the coordinates. `describe` printing `@position`/`@merge` IS the headless oracle: diff those, don't open the modeler. **The split builders had zero positional coverage** before this (grep `Position` in the enum-split and inheritance-split tests: nothing), which is the same gap behind the loop-box sizing bugs #790 and #884. **Pick the IF convention, not the CASE one**: the constants are centre-to-centre for a 40px edge gap, and `CASE`'s `+HorizontalSpacing/2` leaves a following activity's left edge exactly touching the merge (measured: merge 890, activity 970, both edges at 910) — a lesser pre-existing nit deliberately left alone, because changing it re-lays-out every enum split ever written. **`@merge(x,y)` was not a workaround**: `fb.posX = mergeX` read the overridden value, so moving the merge by hand moved the stacked element with it. A fix needs a control that the neighbouring construct did NOT move — `TestEnumSplitGeometryIsUnchanged` — or "the type split matches CASE now" can be achieved by breaking CASE. Issue #953 | diff --git a/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl b/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl new file mode 100644 index 000000000..b72e2f3f2 --- /dev/null +++ b/mdl-examples/bug-tests/953-split-type-merge-overlap.mdl @@ -0,0 +1,114 @@ +-- ============================================================================ +-- SPLIT TYPE stacked the element after END SPLIT on top of its own merge. +-- +-- Reported against v0.19.0: "the end event renders on top of the merge in +-- Studio Pro, connected by a zero-length sequence flow", and separately "the +-- merge x looks over-advanced relative to its branches (branch activities at +-- x=720, merge at x=1480)". Two defects, both in addStructuredInheritanceSplit: +-- +-- 1. It ended with `fb.posX = mergeX` — the merge's own centre — so whatever +-- followed END SPLIT was drawn at exactly that point. addEnumSplit and +-- addIfStatement both step past the merge first. +-- +-- 2. Branch width came from every branch body concatenated into ONE list and +-- measured as a single left-to-right run. Branches are stacked vertically, +-- so only the widest one matters; summing them slid the merge right by an +-- activity-plus-spacing per extra branch. +-- +-- MEASURED on mxbuild 11.13.0 with the three microflows below (describe prints +-- the stored coordinates, so no Studio Pro is needed to see it): +-- +-- merge element after branches +-- ThreeBranches before 1480 1480 (stacked) 720 +-- after 920 1040 720 +-- FourBranches before 1760 1760 (stacked) 720 +-- after 920 1040 720 <- no drift +-- UnevenBranches before 1760 1760 (stacked) 720, 880 +-- after 1200 1320 720, 880 +-- +-- The CONTROL is EnumSplitComparison: identical graph shape as a CASE, and its +-- geometry is byte-identical before and after the fix (merge 890, end 970). If +-- that moved, the fix reached further than it should have. +-- +-- Neither defect is visible below Studio Pro: the model is valid either way, so +-- `mx check` reports 0 errors on the stacked version. Verified. +-- +-- Regression coverage: mdl/executor/cmd_microflows_builder_split_geometry_test.go +-- ============================================================================ + +CREATE OR MODIFY MODULE Bug953; + +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Animal ( "Name": String(50) ); +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Dog GENERALIZATION Bug953.Animal (); +CREATE OR MODIFY PERSISTENT ENTITY Bug953.Cat GENERALIZATION Bug953.Animal (); +CREATE OR MODIFY ENUMERATION Bug953.Kind ( Dog, Other ); + +-- Three branches, one activity each. The `return` after END SPLIT is the +-- element that used to land on the merge. +CREATE OR MODIFY MICROFLOW Bug953.ThreeBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- One more branch, same width. The merge must not move. +CREATE OR MODIFY MICROFLOW Bug953.FourBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + when Bug953.Cat then + $result = 'cat'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- One branch twice as long. The merge must follow the WIDEST branch — this is +-- the control for "does not drift with branch count", which a builder ignoring +-- the branches entirely would also satisfy. +CREATE OR MODIFY MICROFLOW Bug953.UnevenBranches ($Animal: Bug953.Animal) +RETURNS String +BEGIN + declare $result string = 'none'; + split type $Animal + when Bug953.Dog then + $result = 'dog'; + $result = 'dog again'; + when Bug953.Animal then + $result = 'animal'; + when (empty) then + $result = 'empty'; + end split; + return $result; +END; + +-- The control: the same shape as a CASE. Untouched by the fix. +CREATE OR MODIFY MICROFLOW Bug953.EnumSplitComparison ($Kind: Bug953.Kind) +RETURNS String +BEGIN + declare $result string = 'none'; + case $Kind + when Dog then + $result = 'dog'; + when Other then + $result = 'other'; + when (empty) then + $result = 'none'; + end case; + return $result; +END; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 8e8c3362d..e486ef4e4 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -509,7 +509,20 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt fb.pendingAnnotations = nil } - branchWidth := fb.measurer.measureStatements(appendInheritanceBodies(s)).Width + // The branches are laid out one BELOW another, all starting at branchStartX, + // so the room they need is the WIDEST branch — not the total. This used to + // measure every body concatenated into one list, which made the merge slide + // right by a whole activity-plus-spacing per extra branch and left it far + // past the branches it joins (mendixlabs/mxcli#953: three one-activity + // branches starting at x=720 put the merge at x=1480 instead of x=920). + // `layout.go`'s measureInheritanceSplitStatement always took the max, so the + // builder disagreed with its own measurer. + branchWidth := 0 + for _, body := range inheritanceBranchBodies(s) { + if w := fb.measurer.measureStatements(body).Width; w > branchWidth { + branchWidth = w + } + } if branchWidth == 0 { branchWidth = HorizontalSpacing / 2 } @@ -618,7 +631,19 @@ func (fb *flowBuilder) addStructuredInheritanceSplit(s *ast.InheritanceSplitStmt // roundtrip does not accumulate `else` blocks; exec re-creates it here. addBranch("", s.ElseBody) - fb.posX = mergeX + // Where the next element goes. This was `mergeX`, i.e. the merge's own centre, + // so whatever followed `end split` was drawn ON TOP of the merge, joined by a + // zero-length sequence flow (#953). The model is valid either way, so nothing + // below this line — not `mx check`, not the build — can notice. + // + // The spacing constants are centre-to-centre and tuned for a 40px edge gap, so + // clearing a 40-wide merge before a 120-wide activity needs MergeSize + half a + // pitch. That is what addIfStatement uses. addEnumSplit uses only + // HorizontalSpacing/2, which leaves an activity's left edge exactly touching + // the merge (measured: merge 890, activity 970, both edges at 910) — legible, + // but not the house gap, and not worth re-laying out every existing enum split + // to change. + fb.posX = mergeX + MergeSize + HorizontalSpacing/2 fb.posY = centerY fb.endsWithReturn = savedEndsWithReturn if allBranchesReturn { @@ -755,13 +780,18 @@ func appendEnumBodies(s *ast.EnumSplitStmt) []ast.MicroflowStatement { return stmts } -func appendInheritanceBodies(s *ast.InheritanceSplitStmt) []ast.MicroflowStatement { - var stmts []ast.MicroflowStatement +// inheritanceBranchBodies returns each branch of a type split as its own body, +// including the `(empty)` branch. +// +// It deliberately does NOT flatten them into one list. The predecessor did, and +// every caller then measured the branches as if they ran end to end when they +// are in fact stacked vertically (#953). +func inheritanceBranchBodies(s *ast.InheritanceSplitStmt) [][]ast.MicroflowStatement { + bodies := make([][]ast.MicroflowStatement, 0, len(s.Cases)+1) for _, c := range s.Cases { - stmts = append(stmts, c.Body...) + bodies = append(bodies, c.Body) } - stmts = append(stmts, s.ElseBody...) - return stmts + return append(bodies, s.ElseBody) } type inheritanceSplitCaseOrderAnchor struct { diff --git a/mdl/executor/cmd_microflows_builder_split_geometry_test.go b/mdl/executor/cmd_microflows_builder_split_geometry_test.go new file mode 100644 index 000000000..19dfcfeb6 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_split_geometry_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// Geometry tests for the split builders (mendixlabs/mxcli#953). +// +// The split builders had no positional coverage at all before this file — +// grep for `Position` in the enum-split and inheritance-split tests returned +// nothing — which is why a type split shipped drawing the element after +// `END SPLIT` directly on top of its own merge. Nothing downstream can catch +// that: the model is valid, so `mx check` and the build both pass, and the +// overlap is only visible by opening the microflow in Studio Pro. + +// logStmt is a one-activity branch body. +func geoLog(msg string) ast.MicroflowStatement { + return &ast.LogStmt{Level: ast.LogInfo, Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: msg}} +} + +func newGeometryBuilder() *flowBuilder { + return &flowBuilder{spacing: HorizontalSpacing, measurer: &layoutMeasurer{}} +} + +func soleMerge(t *testing.T, fb *flowBuilder) *microflows.ExclusiveMerge { + t.Helper() + var found *microflows.ExclusiveMerge + for _, obj := range fb.objects { + if m, ok := obj.(*microflows.ExclusiveMerge); ok { + if found != nil { + t.Fatal("more than one merge; the fixture is not the shape this test assumes") + } + found = m + } + } + if found == nil { + t.Fatal("no merge was created") + } + return found +} + +// typeSplit builds a type split whose branches each hold one activity. +func typeSplit(branchBodies ...[]ast.MicroflowStatement) *ast.InheritanceSplitStmt { + s := &ast.InheritanceSplitStmt{Variable: "obj"} + for i, body := range branchBodies { + if i == len(branchBodies)-1 { + s.ElseBody = body + break + } + s.Cases = append(s.Cases, ast.InheritanceSplitCase{ + Entity: ast.QualifiedName{Module: "M", Name: "Sub"}, + Body: body, + }) + } + return s +} + +// The element after END SPLIT must not land on the merge. +// +// `addStructuredInheritanceSplit` ended with `fb.posX = mergeX` — the merge's +// own centre — so the end event was drawn on top of it, joined by a zero-length +// sequence flow. Reported from the field as "the end event renders on top of +// the merge in Studio Pro". +func TestTypeSplitLeavesRoomAfterTheMerge(t *testing.T) { + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a")}, + []ast.MicroflowStatement{geoLog("b")}, + )) + + merge := soleMerge(t, fb) + if fb.posX == merge.Position.X { + t.Fatalf("the next element would be placed at x=%d, exactly on the merge", fb.posX) + } + + // The spacing constants are centre-to-centre and tuned for a 40px edge gap, + // so clearing a MergeSize-wide merge before a full-width activity needs + // MergeSize + half a pitch. Same rule addIfStatement uses. + if want := merge.Position.X + MergeSize + HorizontalSpacing/2; fb.posX != want { + t.Errorf("next x = %d, want %d", fb.posX, want) + } + + // The gap actually rendered, stated in edge terms so the intent survives a + // change to the constants. + gap := (fb.posX - ActivityWidth/2) - (merge.Position.X + MergeSize/2) + if gap != HorizontalSpacing-ActivityWidth { + t.Errorf("edge-to-edge gap after the merge = %d, want %d", gap, HorizontalSpacing-ActivityWidth) + } +} + +// The merge is placed from the WIDEST branch, not from all of them end to end. +// +// The width came from every branch body concatenated into one list and measured +// as a single run, so the merge slid right by an activity-plus-spacing for each +// extra branch — reported as "the merge x looks over-advanced relative to its +// branches". Branches are stacked vertically; only the widest one matters. +func TestTypeSplitMergeDoesNotDriftWithBranchCount(t *testing.T) { + build := func(n int) *microflows.ExclusiveMerge { + bodies := make([][]ast.MicroflowStatement, n) + for i := range bodies { + bodies[i] = []ast.MicroflowStatement{geoLog("x")} + } + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit(bodies...)) + return soleMerge(t, fb) + } + + two, five := build(2), build(5) + if two.Position.X != five.Position.X { + t.Errorf("merge moved from x=%d to x=%d when three more one-activity branches were added; "+ + "branch width is being summed across branches instead of maxed", + two.Position.X, five.Position.X) + } + + // And it sits where one branch's worth of room puts it, not somewhere merely + // stable. splitX defaults to 0; the split box is ActivityWidth wide. + branchStartX := ActivityWidth + HorizontalSpacing/2 + if want := branchStartX + ActivityWidth + HorizontalSpacing/2; two.Position.X != want { + t.Errorf("merge x = %d, want %d (branch start + one activity + half a pitch)", two.Position.X, want) + } +} + +// Control for the test above: it must be the branch WIDTH that moves the merge, +// or "does not drift with branch count" would also pass against a builder that +// ignored the branches entirely. +func TestTypeSplitMergeDoesFollowBranchWidth(t *testing.T) { + fb := newGeometryBuilder() + fb.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a"), geoLog("b")}, // the wide one + []ast.MicroflowStatement{geoLog("c")}, + )) + wide := soleMerge(t, fb) + + fb2 := newGeometryBuilder() + fb2.addStructuredInheritanceSplit(typeSplit( + []ast.MicroflowStatement{geoLog("a")}, + []ast.MicroflowStatement{geoLog("c")}, + )) + narrow := soleMerge(t, fb2) + + if wide.Position.X <= narrow.Position.X { + t.Errorf("merge at x=%d for a two-activity branch vs x=%d for a one-activity branch; "+ + "the branches are not being measured at all", wide.Position.X, narrow.Position.X) + } +} + +// The enum split is the construct the field report compared against, and it was +// already correct. Pinned here so a later attempt to unify the two cannot move +// it silently: every existing enum split in every project would be re-laid out. +func TestEnumSplitGeometryIsUnchanged(t *testing.T) { + build := func(n int) (*microflows.ExclusiveMerge, int) { + s := &ast.EnumSplitStmt{Variable: "kind"} + for i := 0; i < n; i++ { + s.Cases = append(s.Cases, ast.EnumSplitCase{ + Values: []string{"V"}, + Body: []ast.MicroflowStatement{geoLog("x")}, + }) + } + s.ElseBody = []ast.MicroflowStatement{geoLog("e")} + fb := newGeometryBuilder() + fb.addEnumSplit(s) + return soleMerge(t, fb), fb.posX + } + + two, nextTwo := build(2) + five, _ := build(5) + if two.Position.X != five.Position.X { + t.Errorf("enum split merge moved with branch count: x=%d then x=%d", two.Position.X, five.Position.X) + } + if want := two.Position.X + HorizontalSpacing/2; nextTwo != want { + t.Errorf("enum split next x = %d, want %d — this test pins existing behaviour; "+ + "changing it re-lays-out every enum split ever written", nextTwo, want) + } +} From ff81a24291085c3f0eedb48a6f77b41becdbf7f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 20:16:34 +0000 Subject: [PATCH 4/7] feat(skills): adopt the Agent Skills standard for the bundled skills (#906) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 67 skills mxcli ships were flat `.md` files with no frontmatter, written only to `.ai-context/skills/`. Nothing discovered them. Routing was a hand-maintained table in the generated CLAUDE.md, and it had drifted: 12 of 67 named there, 31 of 67 in the skills README, 1.09 MB of guidance reachable only through a 26 KB file that listed a sixth of it. `mxcli init` also created no `.claude/skills/` at all — it wrote `.claude/commands/`, `.claude/lint-rules/` and `.claude/settings.json` — so in a Claude Code session on an mxcli project, not one of the skills was visible to the tool the project is mostly used with. Each skill is now a `/SKILL.md` directory with `name` and `description` frontmatter, and init writes the tree twice: `.ai-context/skills/` for every tool (referenced by the generated OpenCode, Cursor, Continue, Windsurf and Aider configs) and `.claude/skills/`, the only path Claude Code scans. The `description` says what the skill covers and when to reach for it, so the index lives in the file it describes and cannot fall out of step with it. The CLAUDE.md table stays as a shortcut to the ones worth reading first, no longer as the index. The format is not new here: mxcli's three skill packs have shipped as SKILL.md with frontmatter since they were introduced, and `mxcli skill add` already installs them to `.claude/skills//`. This gives the prose skills the same shape. Claude Code has supported it since 2.0.20, and merged custom commands into skills while keeping `.claude/commands/*.md` working — the same converge-but-keep-the-old-form-working line taken here. Upgrading a project retires the flat files older binaries wrote, which the sync has never done before. That is deliberate: the sync only ever added and overwrote, which was right while the layout was stable and exactly wrong across a layout change — without it every upgraded project keeps 67 orphans beside the new tree, and an agent reading the directory cannot tell which copy is current. Only names mxcli itself ships are removed, and only once the replacement exists, so a skill the user wrote — which the docs explicitly invite — is never touched. Verified end to end: init writes 67 SKILL.md to both locations, all with valid frontmatter; a simulated upgrade retires the legacy files, keeps the user's own, and is silent on the second run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/mendix/README.md | 138 ++++++++------- .../mendix/{agents.md => agents/SKILL.md} | 5 + .../{alter-page.md => alter-page/SKILL.md} | 15 +- .../SKILL.md} | 23 ++- .../SKILL.md} | 13 +- .../SKILL.md} | 7 +- .../SKILL.md} | 35 ++-- .../SKILL.md} | 15 +- .../SKILL.md} | 7 +- .../SKILL.md} | 7 +- .../SKILL.md} | 5 + .../SKILL.md} | 13 +- .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 21 ++- .../SKILL.md} | 13 +- .../SKILL.md} | 5 + .../{create-page.md => create-page/SKILL.md} | 17 +- .../SKILL.md} | 5 + .../SKILL.md} | 7 +- .../{debug-bson.md => debug-bson/SKILL.md} | 7 +- .../SKILL.md} | 9 +- .../{demo-data.md => demo-data/SKILL.md} | 13 +- .../SKILL.md} | 7 +- .../SKILL.md} | 7 +- .../{fragments.md => fragments/SKILL.md} | 9 +- .../SKILL.md} | 11 +- .../SKILL.md} | 13 +- .../SKILL.md} | 9 +- .../SKILL.md} | 9 +- .../SKILL.md} | 11 +- .../SKILL.md} | 7 +- .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 7 +- .../SKILL.md} | 11 +- .../SKILL.md} | 5 + .../SKILL.md} | 41 +++-- .../SKILL.md} | 17 +- .../SKILL.md} | 15 +- .../SKILL.md} | 19 +- .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 15 +- .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 7 +- .../SKILL.md} | 11 +- .../SKILL.md} | 9 +- .../{rest-client.md => rest-client/SKILL.md} | 11 +- .../mendix/{run-app.md => run-app/SKILL.md} | 5 + .../{run-local.md => run-local/SKILL.md} | 9 +- .../SKILL.md} | 13 +- .../SKILL.md} | 11 +- .../SKILL.md} | 5 + .../mendix/{test-app.md => test-app/SKILL.md} | 19 +- .../SKILL.md} | 13 +- .../SKILL.md} | 5 + .../SKILL.md} | 5 + .../SKILL.md} | 15 +- .../SKILL.md} | 5 + .../SKILL.md} | 13 +- .../SKILL.md} | 7 +- .../SKILL.md} | 5 + .../{write-rules.md => write-rules/SKILL.md} | 9 +- .../SKILL.md} | 17 +- .../SKILL.md} | 7 +- CLAUDE.md | 2 +- Makefile | 12 +- README.md | 2 +- cmd/mxcli/init.go | 34 ++-- cmd/mxcli/init_claudemd.go | 34 ++-- cmd/mxcli/init_skills_standard_test.go | 141 +++++++++++++++ cmd/mxcli/init_skills_sync.go | 163 +++++++++++++++--- cmd/mxcli/init_skills_sync_test.go | 32 ++-- cmd/mxcli/skills_content.go | 9 +- cmd/mxcli/tool_templates.go | 12 +- docs-site/src/tools/bootstrap-prompt.md | 2 +- docs-site/src/tutorial/claude-code.md | 2 +- docs-site/src/tutorial/opencode.md | 2 +- docs-site/src/tutorial/skills.md | 103 ++++++++--- 82 files changed, 1024 insertions(+), 365 deletions(-) rename .claude/skills/mendix/{agents.md => agents/SKILL.md} (95%) rename .claude/skills/mendix/{alter-page.md => alter-page/SKILL.md} (97%) rename .claude/skills/mendix/{analyze-runtime.md => analyze-runtime/SKILL.md} (91%) rename .claude/skills/mendix/{assess-migration.md => assess-migration/SKILL.md} (95%) rename .claude/skills/mendix/{assess-quality.md => assess-quality/SKILL.md} (96%) rename .claude/skills/mendix/{atlas-design.md => atlas-design/SKILL.md} (97%) rename .claude/skills/mendix/{bootstrap-app.md => bootstrap-app/SKILL.md} (95%) rename .claude/skills/mendix/{browse-integrations.md => browse-integrations/SKILL.md} (93%) rename .claude/skills/mendix/{bulk-widget-updates.md => bulk-widget-updates/SKILL.md} (94%) rename .claude/skills/mendix/{business-events.md => business-events/SKILL.md} (94%) rename .claude/skills/mendix/{catalog-search.md => catalog-search/SKILL.md} (91%) rename .claude/skills/mendix/{cheatsheet-errors.md => cheatsheet-errors/SKILL.md} (96%) rename .claude/skills/mendix/{cheatsheet-variables.md => cheatsheet-variables/SKILL.md} (94%) rename .claude/skills/mendix/{check-syntax.md => check-syntax/SKILL.md} (95%) rename .claude/skills/mendix/{connect-rapidminer-graph.md => connect-rapidminer-graph/SKILL.md} (94%) rename .claude/skills/mendix/{create-custom-widget.md => create-custom-widget/SKILL.md} (98%) rename .claude/skills/mendix/{create-page.md => create-page/SKILL.md} (98%) rename .claude/skills/mendix/{custom-widgets.md => custom-widgets/SKILL.md} (98%) rename .claude/skills/mendix/{database-connections.md => database-connections/SKILL.md} (97%) rename .claude/skills/mendix/{debug-bson.md => debug-bson/SKILL.md} (98%) rename .claude/skills/mendix/{debug-microflows.md => debug-microflows/SKILL.md} (93%) rename .claude/skills/mendix/{demo-data.md => demo-data/SKILL.md} (95%) rename .claude/skills/mendix/{docker-workflow.md => docker-workflow/SKILL.md} (98%) rename .claude/skills/mendix/{download-marketplace-content.md => download-marketplace-content/SKILL.md} (98%) rename .claude/skills/mendix/{fragments.md => fragments/SKILL.md} (96%) rename .claude/skills/mendix/{generate-domain-model.md => generate-domain-model/SKILL.md} (99%) rename .claude/skills/mendix/{graph-analysis.md => graph-analysis/SKILL.md} (94%) rename .claude/skills/mendix/{java-actions.md => java-actions/SKILL.md} (98%) rename .claude/skills/mendix/{java-dependencies.md => java-dependencies/SKILL.md} (88%) rename .claude/skills/mendix/{javascript-actions.md => javascript-actions/SKILL.md} (92%) rename .claude/skills/mendix/{json-structures-and-mappings.md => json-structures-and-mappings/SKILL.md} (98%) rename .claude/skills/mendix/{live-edit-with-studio-pro.md => live-edit-with-studio-pro/SKILL.md} (94%) rename .claude/skills/mendix/{manage-navigation.md => manage-navigation/SKILL.md} (97%) rename .claude/skills/mendix/{manage-security.md => manage-security/SKILL.md} (97%) rename .claude/skills/mendix/{master-detail-pages.md => master-detail-pages/SKILL.md} (93%) rename .claude/skills/mendix/{mdl-entities.md => mdl-entities/SKILL.md} (97%) rename .claude/skills/mendix/{migrate-design-prototype.md => migrate-design-prototype/SKILL.md} (96%) rename .claude/skills/mendix/{migrate-k2-nintex.md => migrate-k2-nintex/SKILL.md} (95%) rename .claude/skills/mendix/{migrate-oracle-forms.md => migrate-oracle-forms/SKILL.md} (95%) rename .claude/skills/mendix/{mock-rest-apis.md => mock-rest-apis/SKILL.md} (90%) rename .claude/skills/mendix/{odata-data-sharing.md => odata-data-sharing/SKILL.md} (99%) rename .claude/skills/mendix/{organize-project.md => organize-project/SKILL.md} (97%) rename .claude/skills/mendix/{overview-pages.md => overview-pages/SKILL.md} (96%) rename .claude/skills/mendix/{patterns-crud.md => patterns-crud/SKILL.md} (95%) rename .claude/skills/mendix/{patterns-data-processing.md => patterns-data-processing/SKILL.md} (96%) rename .claude/skills/mendix/{project-settings.md => project-settings/SKILL.md} (96%) rename .claude/skills/mendix/{regular-expressions.md => regular-expressions/SKILL.md} (93%) rename .claude/skills/mendix/{resolve-forward-references.md => resolve-forward-references/SKILL.md} (94%) rename .claude/skills/mendix/{rest-call-from-json.md => rest-call-from-json/SKILL.md} (95%) rename .claude/skills/mendix/{rest-client.md => rest-client/SKILL.md} (96%) rename .claude/skills/mendix/{run-app.md => run-app/SKILL.md} (95%) rename .claude/skills/mendix/{run-local.md => run-local/SKILL.md} (98%) rename .claude/skills/mendix/{runtime-admin-api.md => runtime-admin-api/SKILL.md} (92%) rename .claude/skills/mendix/{scheduled-events-and-queues.md => scheduled-events-and-queues/SKILL.md} (95%) rename .claude/skills/mendix/{system-module.md => system-module/SKILL.md} (98%) rename .claude/skills/mendix/{test-app.md => test-app/SKILL.md} (95%) rename .claude/skills/mendix/{test-microflows.md => test-microflows/SKILL.md} (97%) rename .claude/skills/mendix/{theme-styling.md => theme-styling/SKILL.md} (98%) rename .claude/skills/mendix/{validation-microflows.md => validation-microflows/SKILL.md} (97%) rename .claude/skills/mendix/{verify-with-oql.md => verify-with-oql/SKILL.md} (89%) rename .claude/skills/mendix/{write-lint-rules.md => write-lint-rules/SKILL.md} (99%) rename .claude/skills/mendix/{write-microflows.md => write-microflows/SKILL.md} (99%) rename .claude/skills/mendix/{write-nanoflows.md => write-nanoflows/SKILL.md} (98%) rename .claude/skills/mendix/{write-oql-queries.md => write-oql-queries/SKILL.md} (99%) rename .claude/skills/mendix/{write-rules.md => write-rules/SKILL.md} (91%) rename .claude/skills/mendix/{write-workflows.md => write-workflows/SKILL.md} (93%) rename .claude/skills/mendix/{xpath-constraints.md => xpath-constraints/SKILL.md} (97%) create mode 100644 cmd/mxcli/init_skills_standard_test.go diff --git a/.claude/skills/mendix/README.md b/.claude/skills/mendix/README.md index 3d3972d9b..c35fb4c26 100644 --- a/.claude/skills/mendix/README.md +++ b/.claude/skills/mendix/README.md @@ -2,14 +2,20 @@ Skills for writing Mendix Definition Language (MDL) code correctly. +Each skill is a directory holding a `SKILL.md` with `name` and `description` +frontmatter, following the [Agent Skills](https://agentskills.io) standard — so +an assistant that reads skills finds the whole set on its own. **This index is a +convenience for humans, not the discovery mechanism**, and it does not list every +skill; the directory does. + ## Quick Reference (Cheat Sheets) Start here for quick syntax lookups: | Skill | Purpose | Use When | |-------|---------|----------| -| [cheatsheet-variables.md](cheatsheet-variables.md) | Variable declaration syntax | Declaring variables, fixing declaration errors | -| [cheatsheet-errors.md](cheatsheet-errors.md) | Common errors and fixes | Debugging MDL syntax errors | +| [cheatsheet-variables](cheatsheet-variables/SKILL.md) | Variable declaration syntax | Declaring variables, fixing declaration errors | +| [cheatsheet-errors](cheatsheet-errors/SKILL.md) | Common errors and fixes | Debugging MDL syntax errors | ## Syntax Reference (By Document Type) @@ -17,15 +23,15 @@ Detailed syntax for each MDL document type: | Skill | Purpose | Use When | |-------|---------|----------| -| [mdl-entities.md](mdl-entities.md) | Entity, attribute, association syntax | Creating domain models | -| [write-microflows.md](write-microflows.md) | Microflow syntax reference | Writing microflow logic | -| [write-nanoflows.md](write-nanoflows.md) | Nanoflow syntax reference | Writing client-side nanoflow logic | -| [write-rules.md](write-rules.md) | Rule syntax reference | Writing reusable decision logic a decision calls | -| [write-oql-queries.md](write-oql-queries.md) | OQL query syntax | Creating VIEW entities | -| [create-page.md](create-page.md) | Page and widget syntax | Creating pages | -| [fragments.md](fragments.md) | Fragment (reusable widget group) syntax | Reusing widget patterns across pages | -| [scheduled-events-and-queues.md](scheduled-events-and-queues.md) | Scheduled event (cron) and task queue syntax | Running a microflow on a schedule; bounding background concurrency | -| [regular-expressions.md](regular-expressions.md) | Named validation patterns | Adding an email/phone/identifier regex; changing a shared pattern | +| [mdl-entities](mdl-entities/SKILL.md) | Entity, attribute, association syntax | Creating domain models | +| [write-microflows](write-microflows/SKILL.md) | Microflow syntax reference | Writing microflow logic | +| [write-nanoflows](write-nanoflows/SKILL.md) | Nanoflow syntax reference | Writing client-side nanoflow logic | +| [write-rules](write-rules/SKILL.md) | Rule syntax reference | Writing reusable decision logic a decision calls | +| [write-oql-queries](write-oql-queries/SKILL.md) | OQL query syntax | Creating VIEW entities | +| [create-page](create-page/SKILL.md) | Page and widget syntax | Creating pages | +| [fragments](fragments/SKILL.md) | Fragment (reusable widget group) syntax | Reusing widget patterns across pages | +| [scheduled-events-and-queues](scheduled-events-and-queues/SKILL.md) | Scheduled event (cron) and task queue syntax | Running a microflow on a schedule; bounding background concurrency | +| [regular-expressions](regular-expressions/SKILL.md) | Named validation patterns | Adding an email/phone/identifier regex; changing a shared pattern | ## Patterns (By Use Case) @@ -33,9 +39,9 @@ Common implementation patterns: | Skill | Purpose | Use When | |-------|---------|----------| -| [patterns-crud.md](patterns-crud.md) | Create/Read/Update/Delete patterns | Building CRUD functionality | -| [patterns-data-processing.md](patterns-data-processing.md) | Loops, aggregates, batch processing | Processing lists of data | -| [validation-microflows.md](validation-microflows.md) | Validation feedback patterns | Building form validation | +| [patterns-crud](patterns-crud/SKILL.md) | Create/Read/Update/Delete patterns | Building CRUD functionality | +| [patterns-data-processing](patterns-data-processing/SKILL.md) | Loops, aggregates, batch processing | Processing lists of data | +| [validation-microflows](validation-microflows/SKILL.md) | Validation feedback patterns | Building form validation | ## Integration Skills @@ -43,14 +49,14 @@ External system integration: | Skill | Purpose | Use When | |-------|---------|----------| -| [database-connections.md](database-connections.md) | Mendix Database Connector | Connecting to Oracle, PostgreSQL, etc. via JDBC | -| [demo-data.md](demo-data.md) | Demo data & IMPORT | Seeding data, `import from` bulk import from external DB | -| [rest-client.md](rest-client.md) | REST API consumption | Calling external REST APIs via consumed REST client documents | -| [rest-call-from-json.md](rest-call-from-json.md) | REST CALL end-to-end | JSON Structure → Entities → Import Mapping → REST CALL microflow | -| [mock-rest-apis.md](mock-rest-apis.md) | Mock a REST dependency | Building or debugging a REST integration without the live API; forcing 404/500; running offline or in CI | -| [json-structures-and-mappings.md](json-structures-and-mappings.md) | JSON structures & mappings | CREATE/DESCRIBE JSON structures, import/export mappings, domain model patterns | -| [java-actions.md](java-actions.md) | Custom Java actions | Extending with Java code | -| [download-marketplace-content.md](download-marketplace-content.md) | Marketplace download & install | Adding a marketplace module/widget; downloading a `.mpk`; module-update caveat | +| [database-connections](database-connections/SKILL.md) | Mendix Database Connector | Connecting to Oracle, PostgreSQL, etc. via JDBC | +| [demo-data](demo-data/SKILL.md) | Demo data & IMPORT | Seeding data, `import from` bulk import from external DB | +| [rest-client](rest-client/SKILL.md) | REST API consumption | Calling external REST APIs via consumed REST client documents | +| [rest-call-from-json](rest-call-from-json/SKILL.md) | REST CALL end-to-end | JSON Structure → Entities → Import Mapping → REST CALL microflow | +| [mock-rest-apis](mock-rest-apis/SKILL.md) | Mock a REST dependency | Building or debugging a REST integration without the live API; forcing 404/500; running offline or in CI | +| [json-structures-and-mappings](json-structures-and-mappings/SKILL.md) | JSON structures & mappings | CREATE/DESCRIBE JSON structures, import/export mappings, domain model patterns | +| [java-actions](java-actions/SKILL.md) | Custom Java actions | Extending with Java code | +| [download-marketplace-content](download-marketplace-content/SKILL.md) | Marketplace download & install | Adding a marketplace module/widget; downloading a `.mpk`; module-update caveat | ## Page Patterns @@ -58,20 +64,20 @@ Page-specific patterns: | Skill | Purpose | Use When | |-------|---------|----------| -| [overview-pages.md](overview-pages.md) | List/grid pages | Building overview screens | -| [master-detail-pages.md](master-detail-pages.md) | Master-detail layouts | Building selection-based UIs | -| [bulk-widget-updates.md](bulk-widget-updates.md) | Bulk widget property updates | Changing widget settings across pages | +| [overview-pages](overview-pages/SKILL.md) | List/grid pages | Building overview screens | +| [master-detail-pages](master-detail-pages/SKILL.md) | Master-detail layouts | Building selection-based UIs | +| [bulk-widget-updates](bulk-widget-updates/SKILL.md) | Bulk widget property updates | Changing widget settings across pages | ## Specialized Skills | Skill | Purpose | Use When | |-------|---------|----------| -| [bootstrap-app.md](bootstrap-app.md) | Provision a new Mendix app in an empty repo | Starting from nothing: interview, `mxcli new`, hook + brief, commit, boot | -| [generate-domain-model.md](generate-domain-model.md) | Complete domain model generation | Generating full domain models | -| [create-custom-widget.md](create-custom-widget.md) | Custom pluggable widget AIGC | Creating custom React widgets from scratch | -| [migrate-design-prototype.md](migrate-design-prototype.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | -| [debug-bson.md](debug-bson.md) | BSON debugging | Troubleshooting SDK issues | -| [analyze-runtime.md](analyze-runtime.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | +| [bootstrap-app](bootstrap-app/SKILL.md) | Provision a new Mendix app in an empty repo | Starting from nothing: interview, `mxcli new`, hook + brief, commit, boot | +| [generate-domain-model](generate-domain-model/SKILL.md) | Complete domain model generation | Generating full domain models | +| [create-custom-widget](create-custom-widget/SKILL.md) | Custom pluggable widget AIGC | Creating custom React widgets from scratch | +| [migrate-design-prototype](migrate-design-prototype/SKILL.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | +| [debug-bson](debug-bson/SKILL.md) | BSON debugging | Troubleshooting SDK issues | +| [analyze-runtime](analyze-runtime/SKILL.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | --- @@ -83,33 +89,33 @@ Load skills based on the task: | User Request | Load These Skills | |--------------|-------------------| -| "Set this empty repo up as a Mendix app" | `bootstrap-app.md` | -| "Create entity/domain model" | `mdl-entities.md` | -| "Write microflow" | `write-microflows.md`, `cheatsheet-variables.md` | -| "Create validation" | `validation-microflows.md`, `patterns-crud.md` | -| "Add CRUD operations" | `patterns-crud.md` | -| "Process list of items" | `patterns-data-processing.md` | -| "Fix MDL error" | `cheatsheet-errors.md` | -| "Import data from database" | `demo-data.md` | -| "Call a REST API / integrate JSON endpoint" | `rest-call-from-json.md` | -| "Create JSON structure / import mapping" | `json-structures-and-mappings.md` | -| "Create export mapping" | `json-structures-and-mappings.md` | -| "Map JSON to entities" | `json-structures-and-mappings.md` | -| "Seed/populate test data" | `demo-data.md` | -| "Run a microflow nightly / hourly / on a schedule" | `scheduled-events-and-queues.md` | -| "Add a cron job / batch job / recurring task" | `scheduled-events-and-queues.md` | -| "Limit how many background tasks run at once" | `scheduled-events-and-queues.md` | -| "Add a validation pattern / email regex" | `regular-expressions.md` | -| "Update widget properties" | `bulk-widget-updates.md` | -| "Change widgets in bulk" | `bulk-widget-updates.md` | -| "Reuse widgets across pages" | `fragments.md` | -| "Define a fragment" | `fragments.md` | -| "Create custom widget" | `create-custom-widget.md` | -| "Build a pluggable widget" | `create-custom-widget.md` | -| "Turn a design prototype/handoff into a Mendix app" | `migrate-design-prototype.md`, `theme-styling.md`, `create-page.md` | -| "Build/apply a theme from a design" | `migrate-design-prototype.md`, `theme-styling.md` | -| "Why is this slow / profile the app / what hits the database" | `analyze-runtime.md`, `run-local.md` | -| "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime.md` | +| "Set this empty repo up as a Mendix app" | `bootstrap-app` | +| "Create entity/domain model" | `mdl-entities` | +| "Write microflow" | `write-microflows`, `cheatsheet-variables` | +| "Create validation" | `validation-microflows`, `patterns-crud` | +| "Add CRUD operations" | `patterns-crud` | +| "Process list of items" | `patterns-data-processing` | +| "Fix MDL error" | `cheatsheet-errors` | +| "Import data from database" | `demo-data` | +| "Call a REST API / integrate JSON endpoint" | `rest-call-from-json` | +| "Create JSON structure / import mapping" | `json-structures-and-mappings` | +| "Create export mapping" | `json-structures-and-mappings` | +| "Map JSON to entities" | `json-structures-and-mappings` | +| "Seed/populate test data" | `demo-data` | +| "Run a microflow nightly / hourly / on a schedule" | `scheduled-events-and-queues` | +| "Add a cron job / batch job / recurring task" | `scheduled-events-and-queues` | +| "Limit how many background tasks run at once" | `scheduled-events-and-queues` | +| "Add a validation pattern / email regex" | `regular-expressions` | +| "Update widget properties" | `bulk-widget-updates` | +| "Change widgets in bulk" | `bulk-widget-updates` | +| "Reuse widgets across pages" | `fragments` | +| "Define a fragment" | `fragments` | +| "Create custom widget" | `create-custom-widget` | +| "Build a pluggable widget" | `create-custom-widget` | +| "Turn a design prototype/handoff into a Mendix app" | `migrate-design-prototype`, `theme-styling`, `create-page` | +| "Build/apply a theme from a design" | `migrate-design-prototype`, `theme-styling` | +| "Why is this slow / profile the app / what hits the database" | `analyze-runtime`, `run-local` | +| "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime` | ### For Error Recovery @@ -117,11 +123,11 @@ When encountering errors: | Error Type | Load This Skill | |------------|-----------------| -| Variable not declared | `cheatsheet-variables.md` | -| Entity type syntax | `cheatsheet-errors.md` | -| Association path error | `cheatsheet-errors.md` | -| Microflow structure error | `write-microflows.md` | -| OQL syntax error | `write-oql-queries.md` | +| Variable not declared | `cheatsheet-variables` | +| Entity type syntax | `cheatsheet-errors` | +| Association path error | `cheatsheet-errors` | +| Microflow structure error | `write-microflows` | +| OQL syntax error | `write-oql-queries` | --- @@ -141,9 +147,9 @@ When encountering errors: ### REPL Integration ```bash -mdl> help variables # Load cheatsheet-variables.md -mdl> help errors # Load cheatsheet-errors.md -mdl> help crud # Load patterns-crud.md +mdl> help variables # Load cheatsheet-variables +mdl> help errors # Load cheatsheet-errors +mdl> help crud # Load patterns-crud ``` ### Check Command diff --git a/.claude/skills/mendix/agents.md b/.claude/skills/mendix/agents/SKILL.md similarity index 95% rename from .claude/skills/mendix/agents.md rename to .claude/skills/mendix/agents/SKILL.md index 6af50b85a..c18ab794a 100644 --- a/.claude/skills/mendix/agents.md +++ b/.claude/skills/mendix/agents/SKILL.md @@ -1,3 +1,8 @@ +--- +name: agents +description: "Author Mendix AI agent documents in MDL — Model, Knowledge Base, Consumed MCP Service and Agent, with variables, tools and multi-line prompts. Use when adding GenAI features to a Mendix app, wiring an LLM model or knowledge base, or listing/describing existing agent documents. Requires AgentEditorCommons and Mendix 11.9+." +--- + # Agents ## Overview diff --git a/.claude/skills/mendix/alter-page.md b/.claude/skills/mendix/alter-page/SKILL.md similarity index 97% rename from .claude/skills/mendix/alter-page.md rename to .claude/skills/mendix/alter-page/SKILL.md index 2ab9a2615..c41a91a08 100644 --- a/.claude/skills/mendix/alter-page.md +++ b/.claude/skills/mendix/alter-page/SKILL.md @@ -1,3 +1,8 @@ +--- +name: alter-page +description: "Modify an existing page or snippet's widget tree in place with ALTER PAGE / ALTER SNIPPET — SET, INSERT, DROP, REPLACE and SET Layout. Use when changing a caption, style or property, adding or removing a widget, or reordering a form, instead of rewriting the whole page with CREATE OR REPLACE." +--- + # ALTER PAGE / ALTER SNIPPET - Modify Existing Pages and Snippets ## Overview @@ -13,7 +18,7 @@ ALTER PAGE and ALTER SNIPPET modify an existing page or snippet's widget tree ** | Remove unused widgets | `alter page` with `drop` | | Replace a footer or section | `alter page` with `replace` | | Several related changes on the same page | `alter page` with multiple operations in one block | -| Same property across many pages (e.g., add `Class` to every Container) | `update widgets` — see `bulk-widget-updates.md` | +| Same property across many pages (e.g., add `Class` to every Container) | `update widgets` — see `bulk-widget-updates` | | Rebuild entire page from scratch | `create or replace page` | | Create a new page | `create page` | @@ -51,7 +56,7 @@ alter page MyMod.Product_Overview { }; ``` -For changes that should be applied across **many pages** (e.g., "add `Class='card'` to every Container in `MyMod`"), use `UPDATE WIDGETS` instead — see `bulk-widget-updates.md`. +For changes that should be applied across **many pages** (e.g., "add `Class='card'` to every Container in `MyMod`"), use `UPDATE WIDGETS` instead — see `bulk-widget-updates`. ## Operations @@ -514,6 +519,6 @@ between *different* microflows, not within the page itself. ## Related Skills -- [Create Page](./create-page.md) - Full page creation syntax -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern +- [Create Page](../create-page/SKILL.md) - Full page creation syntax +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern diff --git a/.claude/skills/mendix/analyze-runtime.md b/.claude/skills/mendix/analyze-runtime/SKILL.md similarity index 91% rename from .claude/skills/mendix/analyze-runtime.md rename to .claude/skills/mendix/analyze-runtime/SKILL.md index 1d41fd3dc..d8033e9dd 100644 --- a/.claude/skills/mendix/analyze-runtime.md +++ b/.claude/skills/mendix/analyze-runtime/SKILL.md @@ -1,3 +1,8 @@ +--- +name: analyze-runtime +description: "Find out what a running Mendix app actually does — logs, Prometheus metrics, OpenTelemetry traces and the model catalog, joined across sources. Use when profiling a slow page or microflow, finding what hits the database, chasing an error that shows only a generic dialog, or correlating runtime cost with model shape." +--- + # Analyze an App's Runtime Behavior — Logs, Metrics, Traces, Catalog ## Overview @@ -22,7 +27,7 @@ sources that no single tool answers alone. - A server-side error shows only a generic dialog in the browser. - You're profiling and need a flame chart, or want cost correlated with model shape. -Prerequisite: run the app with the fast local loop — see `run-local.md`. Everything +Prerequisite: run the app with the fast local loop — see `run-local`. Everything below assumes `mxcli run --local` (add the flags noted per signal). ## 1. Logs — the first stop for errors @@ -40,7 +45,7 @@ tail -f .mxcli/runtime.log Gotchas: - **Nanoflow `LOG` lands under the `Client_Nanoflow` node**, not the node name you declared — a filter built around microflow node names silently drops it. `LOG DEBUG` - from a nanoflow is dropped server-side (browser console only). See `write-nanoflows.md`. + from a nanoflow is dropped server-side (browser console only). See `write-nanoflows`. - A spike in "Executing N database synchronization command(s)" on an *unchanged* model is a red flag (see the `create or modify` data-loss class of bug). @@ -108,7 +113,7 @@ That same probe showed Mendix rejecting `$filter` on a property not declared `Filterable`, with a **400 "Property 'rowKey' is non-filterable"**, before the read microflow ran. So the platform does enforce the filterability you declare in `expose (…)`; what it does *not* do is apply `$top`/`$skip`/`$orderby` for a -read-microflow resource (see `odata-data-sharing.md`). +read-microflow resource (see `odata-data-sharing`). ## 2. Metrics — throughput and database pressure @@ -163,8 +168,8 @@ mxcli -p app.mpr -c "SELECT MicroflowQualifiedName, COUNT(*) activities ``` `CATALOG.ACTIVITIES.Id` is the model GUID the debugger breaks on (see -`debug-microflows.md`), with the action name and its sequence — a named, ordered -activity list per microflow. See `catalog-search.md` / `graph-analysis.md` for the +`debug-microflows`), with the action name and its sequence — a named, ordered +activity list per microflow. See `catalog-search` / `graph-analysis` for the richer queries. ## 5. The app warehouse — join the signals (external DuckDB) @@ -215,7 +220,7 @@ export traces (and logs) via OTLP with `--trace-otlp` so the backend joins them. ## See Also -- `run-local.md` — the local loop these flags hang off (`--metrics` / `--trace` / `--trace-otlp` / `--runtime-setting` reference). -- `debug-microflows.md` — interactive breakpoints/stepping when a trace isn't enough. -- `catalog-search.md`, `graph-analysis.md` — catalog query patterns and dependency analysis. -- `verify-with-oql.md` — query the running app's data directly. +- `run-local` — the local loop these flags hang off (`--metrics` / `--trace` / `--trace-otlp` / `--runtime-setting` reference). +- `debug-microflows` — interactive breakpoints/stepping when a trace isn't enough. +- `catalog-search`, `graph-analysis` — catalog query patterns and dependency analysis. +- `verify-with-oql` — query the running app's data directly. diff --git a/.claude/skills/mendix/assess-migration.md b/.claude/skills/mendix/assess-migration/SKILL.md similarity index 95% rename from .claude/skills/mendix/assess-migration.md rename to .claude/skills/mendix/assess-migration/SKILL.md index 08b364127..2560e6031 100644 --- a/.claude/skills/mendix/assess-migration.md +++ b/.claude/skills/mendix/assess-migration/SKILL.md @@ -1,3 +1,8 @@ +--- +name: assess-migration +description: "Investigate an existing non-Mendix application (Java, .NET, Python, Node, PHP, …) and produce a structured migration assessment for Mendix. Use when asked to analyse a legacy codebase for migration, build a migration inventory, or size the effort before any MDL is written." +--- + # Migration Assessment: Investigating Non-Mendix Projects This skill guides the investigation of existing non-Mendix applications to produce a structured migration assessment for Mendix. @@ -337,7 +342,7 @@ Combine all findings into a structured report: ## Related Skills -- [/migrate-oracle-forms](./migrate-oracle-forms.md) - Oracle Forms-specific migration -- [/generate-domain-model](./generate-domain-model.md) - Creating entities and associations in MDL -- [/write-microflows](./write-microflows.md) - Implementing business logic in MDL -- [/organize-project](./organize-project.md) - Folder structure for the migrated project +- [/migrate-oracle-forms](../migrate-oracle-forms/SKILL.md) - Oracle Forms-specific migration +- [/generate-domain-model](../generate-domain-model/SKILL.md) - Creating entities and associations in MDL +- [/write-microflows](../write-microflows/SKILL.md) - Implementing business logic in MDL +- [/organize-project](../organize-project/SKILL.md) - Folder structure for the migrated project diff --git a/.claude/skills/mendix/assess-quality.md b/.claude/skills/mendix/assess-quality/SKILL.md similarity index 96% rename from .claude/skills/mendix/assess-quality.md rename to .claude/skills/mendix/assess-quality/SKILL.md index df881c1c6..774cf4116 100644 --- a/.claude/skills/mendix/assess-quality.md +++ b/.claude/skills/mendix/assess-quality/SKILL.md @@ -1,3 +1,8 @@ +--- +name: assess-quality +description: "Audit a Mendix project against best practices for naming, security, performance, maintainability and architecture, and report a scored result. Use when asked to evaluate project quality, run a pre-go-live health check, or get oriented in an unfamiliar app." +--- + # Assess Mendix Project Quality This skill guides a comprehensive quality assessment of a Mendix project, evaluating it against established best practices across naming, security, performance, maintainability, architecture, and more. @@ -59,7 +64,7 @@ ORDER by activity_count desc mxcli models the project as a dependency graph and exposes topological analyses on top of it — god nodes, coupling, cohesion, communities, centrality, cycles, and layering. These answer questions catalog metrics can't: what's central, what's tangled, what belongs together. -**Read [`graph-analysis.md`](./graph-analysis.md) before running graph analysis.** It covers setup (two-step refresh required), all eight use cases (impact assessment, dead code, cohesion, communities, bridges, hotspots, cycles, monolith splits), column schemas, false-positive caveats for dead assets, and a quick-reference table mapping questions to tables. +**Read [`graph-analysis`](../graph-analysis/SKILL.md) before running graph analysis.** It covers setup (two-step refresh required), all eight use cases (impact assessment, dead code, cohesion, communities, bridges, hotspots, cycles, monolith splits), column schemas, false-positive caveats for dead assets, and a quick-reference table mapping questions to tables. ### Step 3: Manual Review Against Guidelines diff --git a/.claude/skills/mendix/atlas-design.md b/.claude/skills/mendix/atlas-design/SKILL.md similarity index 97% rename from .claude/skills/mendix/atlas-design.md rename to .claude/skills/mendix/atlas-design/SKILL.md index 5aab6983a..b0f418b39 100644 --- a/.claude/skills/mendix/atlas-design.md +++ b/.claude/skills/mendix/atlas-design/SKILL.md @@ -1,3 +1,8 @@ +--- +name: atlas-design +description: "Make a Mendix app look designed rather than default-Atlas: layout, spacing, typography, colour and design properties that reach a finished standard. Use when asked to make an app look professional, branded or less bland, when styling pages, or when matching a design mock." +--- + # Atlas Design — Make a Mendix App Look Designed, Not Bland ## When to Use This Skill @@ -9,9 +14,9 @@ Use this skill when: - You are re-branding an existing app to a new identity (palette, type, corners) This is the **taste + workflow** layer. It sits on top of the styling mechanics -(`theme-styling.md`), the widget syntax (`create-page.md`), the composition -primitives (`fragments.md`), and the design-handoff pipeline -(`migrate-design-prototype.md`). It does **not** re-teach SCSS compilation or +(`theme-styling`), the widget syntax (`create-page`), the composition +primitives (`fragments`), and the design-handoff pipeline +(`migrate-design-prototype`). It does **not** re-teach SCSS compilation or `Class:`/`DesignProperties:` syntax — those skills own that. It adds **which** tokens/classes to use, **when**, and the **discover → inspect → use** method built on the Atlas building blocks every Mendix project already ships. @@ -79,7 +84,7 @@ Layer 0 ATLAS Atlas classes / design properties / building blocks — str *last* — after Atlas Core and after every module theme source — so your rules win without `!important`. Use `themesource//web/main.scss` only when the styling belongs to that module: a theme source folder whose name does not match a real - module is **silently not compiled**. See `theme-styling.md`. + module is **silently not compiled**. See `theme-styling`. - **Layer 3 — Verify.** Non-negotiable. `mx check` misses client-side crashes; you must screenshot a *running* build. @@ -381,7 +386,7 @@ Apply via `class:` on any widget (space-join several: `class:'card flex-column'` Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a specific widget offers, run `show design properties` / `describe styling` -(`theme-styling.md`). +(`theme-styling`). ### When to reach for each @@ -422,7 +427,7 @@ more idiomatic form to mirror from a `describe building block`. Notes: - Compound properties (Spacing, Border) take a **nested list**: `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. - **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` - or wrap in a styled `container`. (`theme-styling.md`.) + or wrap in a styled `container`. (`theme-styling`.) --- @@ -500,7 +505,7 @@ per-widget CSS. moment anything flips the palette — the failure is total and silent. If you genuinely need a token the theme does not expose, add it to the palette -block and reference it from your own Layer-2 rules. See `theme-styling.md` for +block and reference it from your own Layer-2 rules. See `theme-styling` for the compile order and for why `theme/web/main.scss` is the only correct home for app-level rules. @@ -558,7 +563,7 @@ scaffold). The generic `dataviz` skill is the HTML/React analogue of this — sa **Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types (incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see -`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets.md`. +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets`. --- @@ -700,7 +705,7 @@ mxcli run --local -p app.mpr --watch --screenshot **From an egress-only environment (Claude Code web):** `--hub ` reverse-tunnels the local app out over a single 443 connection to a relay, giving a public URL you can -open in a real browser. `--hub` implies `--local`. See `run-local.md` for the flags. +open in a real browser. `--hub` implies `--local`. See `run-local` for the flags. **What a screenshot can't catch — drive the interaction.** A single screenshot is a static frame; the Slider `findDOMNode` throw fires on drag, a filter popover's white @@ -798,9 +803,9 @@ row before opening files. ## Related skills -- `theme-styling.md` — SCSS compilation chain, hot-reload, styling caveats -- `migrate-design-prototype.md` — turning a Claude Design handoff into a theme + pages -- `create-page.md` — page/widget syntax -- `alter-page.md` — in-place widget edits -- `fragments.md` — reusable widget groups (how the mirror recipes stay DRY) -- `run-local.md` — the warm dev loop and screenshot flags +- `theme-styling` — SCSS compilation chain, hot-reload, styling caveats +- `migrate-design-prototype` — turning a Claude Design handoff into a theme + pages +- `create-page` — page/widget syntax +- `alter-page` — in-place widget edits +- `fragments` — reusable widget groups (how the mirror recipes stay DRY) +- `run-local` — the warm dev loop and screenshot flags diff --git a/.claude/skills/mendix/bootstrap-app.md b/.claude/skills/mendix/bootstrap-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/bootstrap-app.md rename to .claude/skills/mendix/bootstrap-app/SKILL.md index e31b2f7c1..0b16c85aa 100644 --- a/.claude/skills/mendix/bootstrap-app.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: bootstrap-app +description: "Provision a Mendix project in a repo that has none — interview, `mxcli new`, session hook, project brief, first commit and boot. Use when the repository is empty or has no .mpr yet, typically from the empty-repo seed prompt." +--- + # Bootstrap a Mendix App in an Empty Repo ## When to Use This Skill @@ -13,9 +18,9 @@ mxcli rather than by re-pasting a longer prompt. If an `.mpr` already exists, this is the wrong skill: run `mxcli init` in the app folder and go straight to the work. -Related skills: `run-local.md` (the warm dev loop this ends in), `mdl-entities.md` and -`create-page.md` (building the model you propose at the end), -`migrate-design-prototype.md` (when a design was handed to you). +Related skills: `run-local` (the warm dev loop this ends in), `mdl-entities` and +`create-page` (building the model you propose at the end), +`migrate-design-prototype` (when a design was handed to you). --- @@ -208,7 +213,7 @@ named after it. From the brief, propose in chat: Show it as **MDL the user can read**, and wait for their go-ahead before executing it. If a design was handed to you, it is the source of truth for the model and the pages — -see `migrate-design-prototype.md`. +see `migrate-design-prototype`. --- @@ -229,5 +234,5 @@ resolve: --app-port 8180 --admin-port 8190 --serve-port 6643) ``` -See `run-local.md` for the warm loop, `--watch`, `--ensure-db`, and the screenshot +See `run-local` for the warm loop, `--watch`, `--ensure-db`, and the screenshot flags. diff --git a/.claude/skills/mendix/browse-integrations.md b/.claude/skills/mendix/browse-integrations/SKILL.md similarity index 93% rename from .claude/skills/mendix/browse-integrations.md rename to .claude/skills/mendix/browse-integrations/SKILL.md index ee771a4a8..c248e7426 100644 --- a/.claude/skills/mendix/browse-integrations.md +++ b/.claude/skills/mendix/browse-integrations/SKILL.md @@ -1,8 +1,13 @@ +--- +name: browse-integrations +description: "Discover the external services a project consumes and browse their cached contracts through the MDL CATALOG — OData entities and actions, REST clients and operations, AsyncAPI channels and messages. Use when asked what integrations exist, what an OData service offers, or what a business-event contract contains." +--- + # Browse Integration Services and Contracts This skill covers discovering external services, browsing cached contracts, and querying integration assets via the **MDL CATALOG** (local project metadata). -**⚠️ NOTE:** This covers the **MDL CATALOG keyword** (`SELECT ... FROM CATALOG.entities`), NOT the **Mendix Catalog CLI** (`mxcli catalog search`). See `.claude/skills/mendix/catalog-search.md` for the external service registry. +**⚠️ NOTE:** This covers the **MDL CATALOG keyword** (`SELECT ... FROM CATALOG.entities`), NOT the **Mendix Catalog CLI** (`mxcli catalog search`). See `.claude/skills/mendix/catalog-search` for the external service registry. ## When to Use This Skill diff --git a/.claude/skills/mendix/bulk-widget-updates.md b/.claude/skills/mendix/bulk-widget-updates/SKILL.md similarity index 94% rename from .claude/skills/mendix/bulk-widget-updates.md rename to .claude/skills/mendix/bulk-widget-updates/SKILL.md index 51979dc0e..8c8c95a7c 100644 --- a/.claude/skills/mendix/bulk-widget-updates.md +++ b/.claude/skills/mendix/bulk-widget-updates/SKILL.md @@ -1,3 +1,8 @@ +--- +name: bulk-widget-updates +description: "Discover and bulk-change widget properties across many pages with SHOW WIDGETS and UPDATE WIDGETS. Use when the same widget setting has to change on many pages at once. Experimental — always DRY RUN first and back the project up." +--- + # Bulk Widget Property Updates > **EXPERIMENTAL**: These commands are an untested proof-of-concept. @@ -9,7 +14,7 @@ Use `show widgets` and `update widgets` to discover and modify widget properties | Scope | Tool | |-------|------| -| One page, one or several related changes | `alter page` — see `alter-page.md` | +| One page, one or several related changes | `alter page` — see `alter-page` | | Many pages matched by `WHERE` filter (across one or more modules) | `update widgets` (this skill) | `alter page` is the right call for targeted, page-specific edits — it can combine `set`/`insert`/`drop`/`replace` in a single block. `update widgets` is for cross-page patterns like "set `Class` on every Container in `MyMod`" where naming each page explicitly would be tedious or error-prone. diff --git a/.claude/skills/mendix/business-events.md b/.claude/skills/mendix/business-events/SKILL.md similarity index 94% rename from .claude/skills/mendix/business-events.md rename to .claude/skills/mendix/business-events/SKILL.md index 81822a75a..1925c3e9f 100644 --- a/.claude/skills/mendix/business-events.md +++ b/.claude/skills/mendix/business-events/SKILL.md @@ -1,3 +1,8 @@ +--- +name: business-events +description: "Define event-driven APIs over Kafka with Mendix business event services — publish and subscribe contracts, CREATE/DROP/DESCRIBE. Use when building publish/subscribe integration between apps, or inspecting the business event services a project already has." +--- + # Business Events ## When to Use This Skill diff --git a/.claude/skills/mendix/catalog-search.md b/.claude/skills/mendix/catalog-search/SKILL.md similarity index 91% rename from .claude/skills/mendix/catalog-search.md rename to .claude/skills/mendix/catalog-search/SKILL.md index a1e2936bb..beabb4262 100644 --- a/.claude/skills/mendix/catalog-search.md +++ b/.claude/skills/mendix/catalog-search/SKILL.md @@ -1,8 +1,13 @@ +--- +name: catalog-search +description: "Search the Mendix Catalog platform service registry (catalog.mendix.com) from the CLI to find services published across an organisation. Use when looking for an existing published service to consume. This is the external registry, not the MDL CATALOG keyword — for that see browse-integrations." +--- + # Catalog Search (Mendix Platform Service Registry) Search and discover services registered in **Mendix Catalog** (catalog.mendix.com) programmatically. -**⚠️ NOTE:** This is the **external Mendix Catalog service** (CLI: `mxcli catalog search`), NOT the **MDL CATALOG keyword** which queries local project metadata tables (`SELECT ... FROM CATALOG.entities`). See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG queries. +**⚠️ NOTE:** This is the **external Mendix Catalog service** (CLI: `mxcli catalog search`), NOT the **MDL CATALOG keyword** which queries local project metadata tables (`SELECT ... FROM CATALOG.entities`). See `.claude/skills/mendix/browse-integrations` for MDL CATALOG queries. ## Authentication Required @@ -160,10 +165,10 @@ See GitHub issue #213 for architecture discussion. - **Requires**: `REFRESH CATALOG` command (no auth needed) - **Data source**: Your local .mpr file -See `.claude/skills/mendix/browse-integrations.md` for MDL CATALOG usage. +See `.claude/skills/mendix/browse-integrations` for MDL CATALOG usage. ## Related - Platform authentication: `.claude/skills/mendix/platform-auth.md` -- OData client creation: `.claude/skills/mendix/odata-data-sharing.md` -- MDL CATALOG queries: `.claude/skills/mendix/browse-integrations.md` +- OData client creation: `.claude/skills/mendix/odata-data-sharing` +- MDL CATALOG queries: `.claude/skills/mendix/browse-integrations` diff --git a/.claude/skills/mendix/cheatsheet-errors.md b/.claude/skills/mendix/cheatsheet-errors/SKILL.md similarity index 96% rename from .claude/skills/mendix/cheatsheet-errors.md rename to .claude/skills/mendix/cheatsheet-errors/SKILL.md index b23b1875b..73ba39538 100644 --- a/.claude/skills/mendix/cheatsheet-errors.md +++ b/.claude/skills/mendix/cheatsheet-errors/SKILL.md @@ -1,3 +1,8 @@ +--- +name: cheatsheet-errors +description: "Quick fixes for the MDL syntax errors that come up most: undeclared variables, wrong retrieve source, list operations, expression mistakes. Use when `mxcli check` reports an error and you want the fix rather than the reference." +--- + # MDL Common Errors Cheatsheet Quick fixes for common MDL syntax errors. diff --git a/.claude/skills/mendix/cheatsheet-variables.md b/.claude/skills/mendix/cheatsheet-variables/SKILL.md similarity index 94% rename from .claude/skills/mendix/cheatsheet-variables.md rename to .claude/skills/mendix/cheatsheet-variables/SKILL.md index 33e7579cb..d3558a53b 100644 --- a/.claude/skills/mendix/cheatsheet-variables.md +++ b/.claude/skills/mendix/cheatsheet-variables/SKILL.md @@ -1,3 +1,8 @@ +--- +name: cheatsheet-variables +description: "One-page reference for declaring and assigning variables in MDL microflows — every type, with the exact syntax. Use when writing a `declare` and unsure of the spelling, or when a declaration error needs fixing fast." +--- + # MDL Variable Cheatsheet Quick reference for variable declarations in MDL microflows. diff --git a/.claude/skills/mendix/check-syntax.md b/.claude/skills/mendix/check-syntax/SKILL.md similarity index 95% rename from .claude/skills/mendix/check-syntax.md rename to .claude/skills/mendix/check-syntax/SKILL.md index 7f1ca8a49..fa0ed1f64 100644 --- a/.claude/skills/mendix/check-syntax.md +++ b/.claude/skills/mendix/check-syntax/SKILL.md @@ -1,3 +1,8 @@ +--- +name: check-syntax +description: "Validate MDL with `mxcli check` before presenting or executing it, including reference resolution against a project. Use ALWAYS before showing MDL to a user, running `mxcli exec`, or committing a .mdl file — exec refuses exactly what check rejects." +--- + # MDL Syntax Validation Skill This skill ensures MDL scripts are validated before presenting them to users or executing them. @@ -188,8 +193,8 @@ not close it. 1. **Read the skill files:** ```bash - cat .claude/skills/write-microflows.md - cat .claude/skills/overview-pages.md + cat .claude/skills/write-microflows/SKILL.md + cat .claude/skills/overview-pages/SKILL.md ``` 2. **Check help for specific syntax:** @@ -257,7 +262,7 @@ Organize scripts in dependency order: ```mdl -- check-skip: illustrative ordering example; the PHASE 5 page block uses -- shorthand pseudo-syntax (layout/title/parameter/widgets) for brevity, not --- runnable MDL. See create-page.md for the real page syntax. +-- runnable MDL. See create-page for the real page syntax. -- ============================================ -- PHASE 1: Enumerations (no dependencies) -- ============================================ @@ -332,7 +337,7 @@ document is created. that embed the snippet), create a minimal placeholder first, then create the referencing documents, then fill in the placeholder with `CREATE OR MODIFY` — which preserves the original UUID so all existing bindings remain valid - (see [Resolve Forward References](./resolve-forward-references.md)) + (see [Resolve Forward References](../resolve-forward-references/SKILL.md)) Declaration order that avoids most forward references: ``` @@ -415,7 +420,7 @@ After `./mxcli exec script.mdl -p app.mpr` succeeds: ## Related Skills -- [/write-microflows](./write-microflows.md) - Detailed microflow syntax -- [/overview-pages](./overview-pages.md) - Page building syntax -- [/resolve-forward-references](./resolve-forward-references.md) - Placeholder pattern and declaration ordering -- [/migrate-oracle-forms](./migrate-oracle-forms.md) - Migration-specific guidance +- [/write-microflows](../write-microflows/SKILL.md) - Detailed microflow syntax +- [/overview-pages](../overview-pages/SKILL.md) - Page building syntax +- [/resolve-forward-references](../resolve-forward-references/SKILL.md) - Placeholder pattern and declaration ordering +- [/migrate-oracle-forms](../migrate-oracle-forms/SKILL.md) - Migration-specific guidance diff --git a/.claude/skills/mendix/connect-rapidminer-graph.md b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md similarity index 94% rename from .claude/skills/mendix/connect-rapidminer-graph.md rename to .claude/skills/mendix/connect-rapidminer-graph/SKILL.md index 59ef7d5f5..c2ce1aece 100644 --- a/.claude/skills/mendix/connect-rapidminer-graph.md +++ b/.claude/skills/mendix/connect-rapidminer-graph/SKILL.md @@ -1,3 +1,8 @@ +--- +name: connect-rapidminer-graph +description: "Fetch data from a RapidMiner graph mart or any SPARQL 1.1 HTTP endpoint (AnzoGraph and friends) and surface it as Mendix entities. Use when a graph database with a SPARQL endpoint has to feed a Mendix app read-only." +--- + # Connecting Mendix to RapidMiner / AnzoGraph via SPARQL Use this skill when you need to fetch data from a RapidMiner graph mart (or any SPARQL 1.1 HTTP endpoint like AnzoGraph) and surface it in a Mendix app. @@ -302,7 +307,7 @@ For demos, literal credentials inline in the microflow are the simplest and most ## Related skills -- [rest-client.md](./rest-client.md) — REST Client + SEND REST REQUEST pattern (preferred when Basic Auth is not needed or uses simple passwords) -- [json-structures-and-mappings.md](./json-structures-and-mappings.md) — JSON structure / import mapping details -- [rest-call-from-json.md](./rest-call-from-json.md) — inline REST CALL + mapping pipeline -- [write-microflows.md](./write-microflows.md) — microflow syntax reference +- [rest-client](../rest-client/SKILL.md) — REST Client + SEND REST REQUEST pattern (preferred when Basic Auth is not needed or uses simple passwords) +- [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) — JSON structure / import mapping details +- [rest-call-from-json](../rest-call-from-json/SKILL.md) — inline REST CALL + mapping pipeline +- [write-microflows](../write-microflows/SKILL.md) — microflow syntax reference diff --git a/.claude/skills/mendix/create-custom-widget.md b/.claude/skills/mendix/create-custom-widget/SKILL.md similarity index 98% rename from .claude/skills/mendix/create-custom-widget.md rename to .claude/skills/mendix/create-custom-widget/SKILL.md index 360c825a0..bedb94fb4 100644 --- a/.claude/skills/mendix/create-custom-widget.md +++ b/.claude/skills/mendix/create-custom-widget/SKILL.md @@ -1,3 +1,8 @@ +--- +name: create-custom-widget +description: "Build a Mendix pluggable widget from scratch with React and TypeScript and package it as an .mpk. Use when no marketplace or built-in widget covers what is needed and a custom React component has to be written." +--- + # Create Custom Pluggable Widget Build a Mendix pluggable widget from scratch using React + TypeScript. Produces a `.mpk` file ready for Studio Pro. diff --git a/.claude/skills/mendix/create-page.md b/.claude/skills/mendix/create-page/SKILL.md similarity index 98% rename from .claude/skills/mendix/create-page.md rename to .claude/skills/mendix/create-page/SKILL.md index 34870f292..26fce2ba6 100644 --- a/.claude/skills/mendix/create-page.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -1,3 +1,8 @@ +--- +name: create-page +description: "CREATE PAGE syntax reference — parameters, variables, layouts, and the full widget vocabulary. Use before writing any CREATE PAGE statement, or when a widget's property spelling needs checking." +--- + # CREATE PAGE - MDL Syntax Guide ## Overview @@ -1065,7 +1070,7 @@ Notes: - **Slider / RangeSlider**: set `showTooltip: false`. The tooltip calls React's removed `findDOMNode` on Mendix 11, throwing "Could not render widget" on drag — a runtime crash `mx check` cannot catch (only a running build does). See - `atlas-design.md` for the full runtime-verification rule. + `atlas-design` for the full runtime-verification rule. ## Complete Examples @@ -1186,7 +1191,7 @@ alter page Module.Customer_Edit { }; ``` -See the dedicated skill file: [ALTER PAGE/SNIPPET](./alter-page.md) +See the dedicated skill file: [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) ## Conditional Visibility and Editability @@ -1262,9 +1267,9 @@ textbox txtSlug (label: 'Slug', attribute: Slug, editable: [length(Slug) > 0]) > `[Status = MES.EquipmentStatus.Running]` (the `Status` attribute is rooted for you; > the *value* must stay qualified, or it would be mistaken for an attribute). > - **XPath datasource constraint** (`where […]`): the string key works — -> `where [Status = 'Running']` (see [xpath-constraints.md](./xpath-constraints.md)). +> `where [Status = 'Running']` (see [xpath-constraints](../xpath-constraints/SKILL.md)). > - **Microflow expression**: qualified value — -> `$obj/Status = MES.EquipmentStatus.Running` (see [write-microflows.md](./write-microflows.md)). +> `$obj/Status = MES.EquipmentStatus.Running` (see [write-microflows](../write-microflows/SKILL.md)). > > Widget-level visibility does **not** apply to **DataGrid2 column** `visible:` (next > section), which hides/shows the whole column and must use page variables. @@ -1386,5 +1391,5 @@ Run `mxcli widget docs -p app.mpr` to generate complete property documentation f ## See Also -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern diff --git a/.claude/skills/mendix/custom-widgets.md b/.claude/skills/mendix/custom-widgets/SKILL.md similarity index 98% rename from .claude/skills/mendix/custom-widgets.md rename to .claude/skills/mendix/custom-widgets/SKILL.md index 454aceac6..deb5eb8e5 100644 --- a/.claude/skills/mendix/custom-widgets.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -1,3 +1,8 @@ +--- +name: custom-widgets +description: "MDL syntax for pluggable widgets already installed in a project — Gallery, DataGrid2, ComboBox and the rest, including their datasource and column forms. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting." +--- + --- name: mendix-custom-widgets description: Use when writing MDL for GALLERY, COMBOBOX, or third-party pluggable widgets in CREATE PAGE / ALTER PAGE statements. Covers built-in widget syntax, child slots (TEMPLATE/FILTER), adding new custom widgets via .def.json, and engine internals. diff --git a/.claude/skills/mendix/database-connections.md b/.claude/skills/mendix/database-connections/SKILL.md similarity index 97% rename from .claude/skills/mendix/database-connections.md rename to .claude/skills/mendix/database-connections/SKILL.md index 4333c47b8..aa68ec641 100644 --- a/.claude/skills/mendix/database-connections.md +++ b/.claude/skills/mendix/database-connections/SKILL.md @@ -1,3 +1,8 @@ +--- +name: database-connections +description: "Connect a Mendix app to an external database over JDBC with the External Database Connector, and define the queries microflows run against it. Use when the app must read or write Oracle, PostgreSQL, MySQL or SQL Server directly from a microflow." +--- + # Skill: Create External Database Connections ## Purpose @@ -502,7 +507,7 @@ import from source query 'SELECT name, email FROM employees' map (name as Name, email as Email); ``` -See [demo-data.md](./demo-data.md) for details on the Mendix ID system and manual insertion. +See [demo-data](../demo-data/SKILL.md) for details on the Mendix ID system and manual insertion. ## References diff --git a/.claude/skills/mendix/debug-bson.md b/.claude/skills/mendix/debug-bson/SKILL.md similarity index 98% rename from .claude/skills/mendix/debug-bson.md rename to .claude/skills/mendix/debug-bson/SKILL.md index 4de1be2a0..81ce8f252 100644 --- a/.claude/skills/mendix/debug-bson.md +++ b/.claude/skills/mendix/debug-bson/SKILL.md @@ -1,3 +1,8 @@ +--- +name: debug-bson +description: "Diagnose BSON serialization problems in the MPR file — missing properties, wrong storage names, widget definitions Studio Pro rejects. Use when something created through MDL does not appear correctly in Studio Pro, or when a CE error points at a document mxcli wrote." +--- + # BSON Serialization Debugging Skill This skill provides guidance for debugging BSON serialization issues when implementing or fixing Mendix SDK writers. @@ -566,7 +571,7 @@ This applies to any executor function that reads column headers, button captions - [BSON Mapping Specification](../../docs/05-mdl-specification/10-bson-mapping.md) - [Page Widget Serialization](../../sdk/mpr/writer_widgets.go) -- [Create Page Skill](./create-page.md) +- [Create Page Skill](../create-page/SKILL.md) - [Widget Templates README](../../sdk/widgets/templates/README.md) ## Quick Reference diff --git a/.claude/skills/mendix/debug-microflows.md b/.claude/skills/mendix/debug-microflows/SKILL.md similarity index 93% rename from .claude/skills/mendix/debug-microflows.md rename to .claude/skills/mendix/debug-microflows/SKILL.md index 9f286fd13..17b33a592 100644 --- a/.claude/skills/mendix/debug-microflows.md +++ b/.claude/skills/mendix/debug-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: debug-microflows +description: "Drive the Mendix runtime's microflow and nanoflow debugger from the command line with `mxcli debug` — breakpoints by name, variable inspection, step and continue. Use when a microflow produces the wrong result and reading it is not enough." +--- + # Debug Microflows — `mxcli debug` ## Overview @@ -19,7 +24,7 @@ GUIDs (from the `.mpr`). You never deal with raw GUIDs. - You want to confirm a microflow takes the branch/value you expect. For a server **stack trace / `LOG` output** (not stepping), you usually just want -the runtime log — see `run-local.md` (`--runtime-log`). Use the debugger when you +the runtime log — see `run-local` (`--runtime-log`). Use the debugger when you need to **pause and inspect** live execution. ## Prerequisites @@ -103,7 +108,7 @@ gets a **new** `debug_id` after every step — the old one is invalidated. Becau the fresh id each time. Reusing a `--flow ` copied from an earlier `paused` will fail on the second nanoflow step with "could not find … in debug with id". -For **nanoflow log output**, see `write-nanoflows.md` — the runtime rewrites the log +For **nanoflow log output**, see `write-nanoflows` — the runtime rewrites the log node to `Client_Nanoflow`, so grep `runtime.log` for `Client_Nanoflow`, not your node name. diff --git a/.claude/skills/mendix/demo-data.md b/.claude/skills/mendix/demo-data/SKILL.md similarity index 95% rename from .claude/skills/mendix/demo-data.md rename to .claude/skills/mendix/demo-data/SKILL.md index 7377f0e3b..faead4d4b 100644 --- a/.claude/skills/mendix/demo-data.md +++ b/.claude/skills/mendix/demo-data/SKILL.md @@ -1,3 +1,8 @@ +--- +name: demo-data +description: "Seed a Mendix app's PostgreSQL database directly, bypassing the runtime — Mendix's internal ID system, association storage, and bulk `import from` an external database. Use when populating test or demo data, when data is needed before the UI exists, or for imports too large for the runtime." +--- + # Skill: Connect to Application Database and Generate Demo Data ## Purpose @@ -132,7 +137,7 @@ from "tasklist$task"; > app) returns `0` for freshly seeded data — this looks like a seeding failure but > isn't. Run `mxcli docker reload` (or restart the app) after seeding. To confirm > rows landed *before* a reload, query Postgres directly. See -> [verify-with-oql.md](./verify-with-oql.md). +> [verify-with-oql](../verify-with-oql/SKILL.md). --- @@ -466,6 +471,6 @@ Use manual INSERT (described above) when you need: ## Related Skills -- [database-connections.md](./database-connections.md) — Connecting to *external* databases from Mendix microflows -- [project-settings.md](./project-settings.md) — Reading and changing project configuration with `alter settings` -- [generate-domain-model.md](./generate-domain-model.md) — Creating entities before inserting data +- [database-connections](../database-connections/SKILL.md) — Connecting to *external* databases from Mendix microflows +- [project-settings](../project-settings/SKILL.md) — Reading and changing project configuration with `alter settings` +- [generate-domain-model](../generate-domain-model/SKILL.md) — Creating entities before inserting data diff --git a/.claude/skills/mendix/docker-workflow.md b/.claude/skills/mendix/docker-workflow/SKILL.md similarity index 98% rename from .claude/skills/mendix/docker-workflow.md rename to .claude/skills/mendix/docker-workflow/SKILL.md index 4c3e8f572..6a7904910 100644 --- a/.claude/skills/mendix/docker-workflow.md +++ b/.claude/skills/mendix/docker-workflow/SKILL.md @@ -1,3 +1,8 @@ +--- +name: docker-workflow +description: "Build, run and validate a Mendix app in Docker from inside a devcontainer. Use when asked to run the app in Docker or produce a deployable image. For the faster Docker-free loop, prefer run-local." +--- + # Docker Build & Run Skill This skill guides you through building, running, and testing a Mendix application using Docker inside a devcontainer. @@ -85,7 +90,7 @@ mxcli init /path/to/my-app The `mx create-project` command creates an MPR v2 project with the standard Mendix module structure. You can then use the Docker workflow to build and run it. -**Caveat:** Blank projects have no demo users — login will fail until you configure security via MDL or Studio Pro. See `manage-security.md` for setting up demo users. +**Caveat:** Blank projects have no demo users — login will fail until you configure security via MDL or Studio Pro. See `manage-security` for setting up demo users. ## Step-by-Step Workflow diff --git a/.claude/skills/mendix/download-marketplace-content.md b/.claude/skills/mendix/download-marketplace-content/SKILL.md similarity index 98% rename from .claude/skills/mendix/download-marketplace-content.md rename to .claude/skills/mendix/download-marketplace-content/SKILL.md index 5fb771cbe..5c7432314 100644 --- a/.claude/skills/mendix/download-marketplace-content.md +++ b/.claude/skills/mendix/download-marketplace-content/SKILL.md @@ -1,3 +1,8 @@ +--- +name: download-marketplace-content +description: "The full lifecycle of Mendix Marketplace modules and widgets from the CLI — search, download, install, detect local edits, and update. Use when adding a marketplace module or widget, upgrading one, or asking what an upgrade would overwrite." +--- + # Download, Install and Update Marketplace Content This skill covers the full lifecycle of Mendix Marketplace content (modules and widgets) @@ -359,7 +364,7 @@ and DataWidgets 3.5.0 → 3.11.3 (49 files) both reach **0 errors**. ## Worked example: the agent-editor stack on a vanilla app Run end-to-end on 2026-08-12 against a fresh `mxcli new … --version 11.12.1` app. The -modules in `.claude/skills/mendix/agents.md` must all be present before any `create agent` +modules in `.claude/skills/mendix/agents` must all be present before any `create agent` statement will build, and two of the dependencies are neither listed there nor modules. | Step | Content | Id | `--version` | Units | Errors after check | diff --git a/.claude/skills/mendix/fragments.md b/.claude/skills/mendix/fragments/SKILL.md similarity index 96% rename from .claude/skills/mendix/fragments.md rename to .claude/skills/mendix/fragments/SKILL.md index da48a6afa..a0b8a3352 100644 --- a/.claude/skills/mendix/fragments.md +++ b/.claude/skills/mendix/fragments/SKILL.md @@ -1,3 +1,8 @@ +--- +name: fragments +description: "Define reusable widget groups with DEFINE FRAGMENT and place them with USE FRAGMENT. Use when the same widget pattern repeats across pages and should be written once." +--- + # Mendix Fragments Skill ## When to Use This Skill @@ -291,6 +296,6 @@ create page Module.MyPage (...) { ## Related Documentation - `mxcli syntax fragment` — CLI help topic -- `create-page.md` — Page/widget syntax reference -- `overview-pages.md` — CRUD page patterns +- `create-page` — Page/widget syntax reference +- `overview-pages` — CRUD page patterns - Proposal: `docs/11-proposals/proposal_page_composition.md` diff --git a/.claude/skills/mendix/generate-domain-model.md b/.claude/skills/mendix/generate-domain-model/SKILL.md similarity index 99% rename from .claude/skills/mendix/generate-domain-model.md rename to .claude/skills/mendix/generate-domain-model/SKILL.md index 85c8c870e..babbd780d 100644 --- a/.claude/skills/mendix/generate-domain-model.md +++ b/.claude/skills/mendix/generate-domain-model/SKILL.md @@ -1,3 +1,8 @@ +--- +name: generate-domain-model +description: "Generate a complete Mendix domain model in MDL — entities, attributes, associations, enumerations — and validate it. Use when asked for a domain model for a business area (e-commerce, HR, CRM, …) rather than a single entity." +--- + # Creating Mendix Domain Model MDL Scripts Use this skill to generate Mendix domain model scripts in MDL (Mendix Definition Language) format and validate them with the linter. @@ -245,7 +250,7 @@ must cover them. Grant an inherited member exactly like one of the entity's own — and `read *` / `write *` cover them too. Skipping them is Mendix CE0066 "Entity access is out of date". The one exception is entities extending `System.User`, whose inherited platform members Mendix manages and which must not be granted. See -`manage-security.md`. +`manage-security`. #### System Attributes (Auditing) @@ -453,7 +458,7 @@ type reference; > datagrid/gallery) over the reverse then fails MxBuild **CE8812** "A grid > association path must result in a list." (a DataView over the reverse is fine — > it's a single object). With `default` ownership, the reverse is the expected -> to-many collection and a list widget works. See master-detail-pages.md for the +> to-many collection and a list widget works. See master-detail-pages for the > widget patterns. **Delete Behaviors**: @@ -634,7 +639,7 @@ indexed client-side. Model the wide form when the UI needs to address the parts individually (e.g. `N1`…`N9` booleans rather than a packed `Notes` string); reach for a computed/derived value only where a microflow or view-entity OQL produces it into a real attribute. (Same reason the bucket-class idiom exists — see -`migrate-design-prototype.md`.) +`migrate-design-prototype`.) ## Documentation Best Practices diff --git a/.claude/skills/mendix/graph-analysis.md b/.claude/skills/mendix/graph-analysis/SKILL.md similarity index 94% rename from .claude/skills/mendix/graph-analysis.md rename to .claude/skills/mendix/graph-analysis/SKILL.md index 40c0fa7f1..7ce2494ea 100644 --- a/.claude/skills/mendix/graph-analysis.md +++ b/.claude/skills/mendix/graph-analysis/SKILL.md @@ -1,3 +1,8 @@ +--- +name: graph-analysis +description: "Query the project as a dependency graph to assess the blast radius of a change, find dead or risky elements, and evaluate module health. Use when planning a refactor, judging what a change will break, or looking for unused documents." +--- + # Graph Analysis: Impact Assessment & Architecture Evaluation Use this skill when assessing the impact of a change, identifying risky elements, evaluating module health, or planning a structural refactor. The graph tables model the entire project as a dependency graph and expose topology metrics on top of it. @@ -298,7 +303,7 @@ Elements at the same layer can be parallelised safely. A microflow calling somet ## Related Skills -- [assess-quality.md](./assess-quality.md) — Full quality assessment including lint, report scores, and manual review guidelines -- [organize-project.md](./organize-project.md) — MOVE command and folder structure -- [write-lint-rules.md](./write-lint-rules.md) — Encoding a layering policy as an enforced Starlark rule -- [odata-data-sharing.md](./odata-data-sharing.md) — Publishing entities for cross-app access +- [assess-quality](../assess-quality/SKILL.md) — Full quality assessment including lint, report scores, and manual review guidelines +- [organize-project](../organize-project/SKILL.md) — MOVE command and folder structure +- [write-lint-rules](../write-lint-rules/SKILL.md) — Encoding a layering policy as an enforced Starlark rule +- [odata-data-sharing](../odata-data-sharing/SKILL.md) — Publishing entities for cross-app access diff --git a/.claude/skills/mendix/java-actions.md b/.claude/skills/mendix/java-actions/SKILL.md similarity index 98% rename from .claude/skills/mendix/java-actions.md rename to .claude/skills/mendix/java-actions/SKILL.md index ef78d855e..8ecbed84d 100644 --- a/.claude/skills/mendix/java-actions.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -1,3 +1,8 @@ +--- +name: java-actions +description: "Create and call custom Java actions — extending Mendix with server-side Java, and invoking those actions from microflows in MDL. Use when logic needs a Java library, an algorithm microflows cannot express, or an integration only available in Java." +--- + # Mendix Java Actions Skill This skill provides comprehensive guidance for creating and using custom Java actions in Mendix projects. @@ -1118,8 +1123,8 @@ Before deploying Java actions, verify: - [Mendix Java Actions Reference](https://docs.mendix.com/refguide/java-actions/) - [Build Microflow Actions with Java](https://docs.mendix.com/howto/extensibility/howto-connector-kit/) - [Java Programming in Mendix](https://docs.mendix.com/refguide/java-programming/) -- [Write Microflows Skill](./write-microflows.md) -- [Validation Microflows Skill](./validation-microflows.md) +- [Write Microflows Skill](../write-microflows/SKILL.md) +- [Validation Microflows Skill](../validation-microflows/SKILL.md) ## Quick Reference diff --git a/.claude/skills/mendix/java-dependencies.md b/.claude/skills/mendix/java-dependencies/SKILL.md similarity index 88% rename from .claude/skills/mendix/java-dependencies.md rename to .claude/skills/mendix/java-dependencies/SKILL.md index d50e13f0b..74755513c 100644 --- a/.claude/skills/mendix/java-dependencies.md +++ b/.claude/skills/mendix/java-dependencies/SKILL.md @@ -1,3 +1,8 @@ +--- +name: java-dependencies +description: "Add, update, remove and audit a module's Maven/JAR dependencies in MDL — Studio Pro's Module Settings → Java dependencies tab. Use when a Java action needs an external library, when bumping a library version, or when auditing which modules pull what." +--- + # Managing Maven / JAR Dependencies This skill explains how to add, update, and remove Maven/JAR dependencies in a @@ -125,5 +130,5 @@ The version is stored separately and shown in `LIST JAR DEPENDENCIES` output. ## Related Skills -- [java-actions.md](./java-actions.md) — authoring Java actions -- [project-settings.md](./project-settings.md) — other module/project settings +- [java-actions](../java-actions/SKILL.md) — authoring Java actions +- [project-settings](../project-settings/SKILL.md) — other module/project settings diff --git a/.claude/skills/mendix/javascript-actions.md b/.claude/skills/mendix/javascript-actions/SKILL.md similarity index 92% rename from .claude/skills/mendix/javascript-actions.md rename to .claude/skills/mendix/javascript-actions/SKILL.md index b058e49e9..0751d0435 100644 --- a/.claude/skills/mendix/javascript-actions.md +++ b/.claude/skills/mendix/javascript-actions/SKILL.md @@ -1,3 +1,8 @@ +--- +name: javascript-actions +description: "Create, call and drop JavaScript actions — client-side logic called from nanoflows, not microflows. Use when behaviour must run in the browser or on the device: DOM, fetch, geolocation, or wrapping a marketplace JS action." +--- + # Mendix JavaScript Actions Skill Guidance for creating, calling, and dropping **JavaScript actions** in Mendix @@ -11,7 +16,7 @@ called from **nanoflows** (not microflows — those use Java actions). - Calling an existing JavaScript action from a nanoflow - Wrapping a marketplace/native capability exposed as a JS action -For **server-side** custom code, use Java actions instead (see `java-actions.md`). +For **server-side** custom code, use Java actions instead (see `java-actions`). ## Overview @@ -182,6 +187,6 @@ DESCRIBE JAVASCRIPT ACTION Module.Name; -- re-executable MDL (signature + body ## Related Documentation -- `java-actions.md` — server-side custom code (microflows) -- `write-nanoflows.md` — nanoflow syntax and restrictions +- `java-actions` — server-side custom code (microflows) +- `write-nanoflows` — nanoflow syntax and restrictions - `mxcli syntax javascript-action` — quick syntax reference diff --git a/.claude/skills/mendix/json-structures-and-mappings.md b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md similarity index 98% rename from .claude/skills/mendix/json-structures-and-mappings.md rename to .claude/skills/mendix/json-structures-and-mappings/SKILL.md index 3e9a379c9..ae406bb99 100644 --- a/.claude/skills/mendix/json-structures-and-mappings.md +++ b/.claude/skills/mendix/json-structures-and-mappings/SKILL.md @@ -1,3 +1,8 @@ +--- +name: json-structures-and-mappings +description: "Create and manage JSON structures, import mappings and export mappings in MDL, plus the domain-model shapes they map onto. Use when turning a JSON payload into entities, mapping a REST or queue response, or exporting objects as JSON." +--- + # JSON Structures, Import Mappings & Export Mappings This skill covers creating and managing JSON structures, import mappings, and export mappings in Mendix using MDL. @@ -619,7 +624,7 @@ leaves placement alone — it never returns a document to the module root — so adding a folder to an existing script is safe and removing one is a no-op. `describe` emits the clause, so a description replays into the same folder. -See `organize-project.md` for `move` and the full folder story. +See `organize-project` for `move` and the full folder story. ## Common Mistakes diff --git a/.claude/skills/mendix/live-edit-with-studio-pro.md b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md similarity index 94% rename from .claude/skills/mendix/live-edit-with-studio-pro.md rename to .claude/skills/mendix/live-edit-with-studio-pro/SKILL.md index df3a04fa1..7b84fb2dc 100644 --- a/.claude/skills/mendix/live-edit-with-studio-pro.md +++ b/.claude/skills/mendix/live-edit-with-studio-pro/SKILL.md @@ -1,3 +1,8 @@ +--- +name: live-edit-with-studio-pro +description: "Change the model through mxcli while Studio Pro has the project open, with edits appearing live over MCP. Use when Claude Code and Studio Pro run on the same machine and a save-and-reopen cycle is unwanted." +--- + # Live-Editing an Open Studio Pro Project with mxcli (MCP) Use mxcli to change the model **while Studio Pro has the project open**, with edits diff --git a/.claude/skills/mendix/manage-navigation.md b/.claude/skills/mendix/manage-navigation/SKILL.md similarity index 97% rename from .claude/skills/mendix/manage-navigation.md rename to .claude/skills/mendix/manage-navigation/SKILL.md index 563833b7d..46f27514c 100644 --- a/.claude/skills/mendix/manage-navigation.md +++ b/.claude/skills/mendix/manage-navigation/SKILL.md @@ -1,3 +1,8 @@ +--- +name: manage-navigation +description: "Inspect and change navigation profiles in MDL — home pages, menus, login and not-found pages, and role-based routing. Use when asked to change where the app opens, restructure the menu, or route roles to different home pages." +--- + # Navigation Management Skill This skill covers inspecting and modifying Mendix navigation profiles via MDL: home pages, menu items, login pages, role-based routing, and navigation catalog queries. diff --git a/.claude/skills/mendix/manage-security.md b/.claude/skills/mendix/manage-security/SKILL.md similarity index 97% rename from .claude/skills/mendix/manage-security.md rename to .claude/skills/mendix/manage-security/SKILL.md index 6e111a6ac..43aaa9375 100644 --- a/.claude/skills/mendix/manage-security.md +++ b/.claude/skills/mendix/manage-security/SKILL.md @@ -1,3 +1,8 @@ +--- +name: manage-security +description: "Configure Mendix security in MDL — module and user roles, access to microflows, pages and entities, project security level, demo users, and guest access. Use when setting up or changing who can see or do what in an app." +--- + # Security Management Skill This skill covers Mendix security configuration via MDL: module roles, user roles, access control (microflows, pages, entities), project security settings, and demo users. @@ -327,7 +332,7 @@ Security data is available in Starlark lint rules (`.star` files): | `role_mappings()` | list of role_mapping | User role → module role mappings | | `project_security()` | struct or None | Security level, guest access, password policy | -See `write-lint-rules.md` for object property details. +See `write-lint-rules` for object property details. ## Common Workflow: Setting Up Module Security diff --git a/.claude/skills/mendix/master-detail-pages.md b/.claude/skills/mendix/master-detail-pages/SKILL.md similarity index 93% rename from .claude/skills/mendix/master-detail-pages.md rename to .claude/skills/mendix/master-detail-pages/SKILL.md index b6a7ed9eb..2e958c1ae 100644 --- a/.claude/skills/mendix/master-detail-pages.md +++ b/.claude/skills/mendix/master-detail-pages/SKILL.md @@ -1,3 +1,8 @@ +--- +name: master-detail-pages +description: "The master-detail page pattern in MDL: a selectable list beside a detail form driven by SELECTION. Use when building a selection-based screen rather than a plain list or form." +--- + # Master-Detail Pages ## Overview @@ -174,9 +179,9 @@ template template1 { ## Related Skills -- [Overview Pages](./overview-pages.md) - CRUD page patterns -- [Create Page](./create-page.md) - Basic page syntax -- [ALTER PAGE/SNIPPET](./alter-page.md) - Modify existing pages in-place (SET, INSERT, DROP, REPLACE) +- [Overview Pages](../overview-pages/SKILL.md) - CRUD page patterns +- [Create Page](../create-page/SKILL.md) - Basic page syntax +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) - Modify existing pages in-place (SET, INSERT, DROP, REPLACE) ## Implementation Notes diff --git a/.claude/skills/mendix/mdl-entities.md b/.claude/skills/mendix/mdl-entities/SKILL.md similarity index 97% rename from .claude/skills/mendix/mdl-entities.md rename to .claude/skills/mendix/mdl-entities/SKILL.md index fd0ad395b..9c2d8ca63 100644 --- a/.claude/skills/mendix/mdl-entities.md +++ b/.claude/skills/mendix/mdl-entities/SKILL.md @@ -1,3 +1,8 @@ +--- +name: mdl-entities +description: "Complete syntax reference for entities, attributes and associations — every entity kind, attribute type, and association form. Use when writing or altering a domain model and the exact spelling matters." +--- + # MDL Entity Syntax Reference Complete syntax reference for creating entities, attributes, and associations. diff --git a/.claude/skills/mendix/migrate-design-prototype.md b/.claude/skills/mendix/migrate-design-prototype/SKILL.md similarity index 96% rename from .claude/skills/mendix/migrate-design-prototype.md rename to .claude/skills/mendix/migrate-design-prototype/SKILL.md index cdb88eb32..750a13846 100644 --- a/.claude/skills/mendix/migrate-design-prototype.md +++ b/.claude/skills/mendix/migrate-design-prototype/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-design-prototype +description: "Reproduce a Claude Design prototype or design handoff (HTML/CSS, .dc.html export, tokens, screenshots) inside a Mendix app as an SCSS theme plus styled pages. Use when given a design artefact and asked to make the app look like it." +--- + # Migrate a Claude Design Prototype into a Mendix App (Theme + Pages) ## When to Use This Skill @@ -13,10 +18,10 @@ It covers the two halves of the job: 2. **Apply it in pages** — attach the theme's classes to widgets with MDL (`Class:` / `DynamicClasses:` on `create page` / `alter page`). -Related skills: **`atlas-design.md` (read first — the Atlas-first taste + workflow layer)**, -`theme-styling.md` (SCSS compilation chain, hot-reload, styling caveats), -`create-page.md` (widget syntax), `alter-page.md` (in-place widget edits), -`bulk-widget-updates.md` (apply a class across many widgets). +Related skills: **`atlas-design` (read first — the Atlas-first taste + workflow layer)**, +`theme-styling` (SCSS compilation chain, hot-reload, styling caveats), +`create-page` (widget syntax), `alter-page` (in-place widget edits), +`bulk-widget-updates` (apply a class across many widgets). --- @@ -42,7 +47,7 @@ screenshots ──③ reference ──► widgets get Class: / Dyna screen, open the matching screenshot/handoff for that screen and match it — colours, spacing, font, component shapes. Do not invent styling the prototype doesn't show. -**Atlas-first (read `atlas-design.md`).** Reproduce the prototype with what Atlas already +**Atlas-first (read `atlas-design`).** Reproduce the prototype with what Atlas already gives you *before* hand-writing custom SCSS. In order of preference: 1. **An Atlas building block** — `use building block Atlas_Web_Content.Card` / `Pageheader` @@ -61,7 +66,7 @@ gives you *before* hand-writing custom SCSS. In order of preference: class (below) only when Atlas genuinely can't express the shape (bespoke chrome, fractional-track grids, pixel-exact rows). Hand-rolling `.panel`/`.stat`/`.card` SCSS that just re-implements what `class:'card'` already does is the single most common - mistake — see `atlas-design.md`. + mistake — see `atlas-design`. The rest of this skill (custom SCSS components, `.ss-*` classes, ListView row reshaping) is **layer 4** — the identity layer you drop to when the first three don't reach the design. @@ -147,7 +152,7 @@ is there a building block (`show building blocks`) or an Atlas class / design pr (`card`, `btn-*`, `spacing-*`, `flex-*`+`align-*`, `['Card style': on]`) that gets you most of the way? If so, use it and add a thin `.ss-*` class only for the brand delta (colour, radius, font). Re-implementing `card`/`panel`/`btn` from scratch is the mistake -`atlas-design.md` exists to prevent. +`atlas-design` exists to prevent. When Atlas can't express the shape, write **one reusable class** driven by the tokens from step ①. Keep classes small and composable so a widget can stack several @@ -290,7 +295,7 @@ runtime (verified on the dashboard). **Real Mendix Charts** (BarChart/LineChart/PieChart/HeatMap/…) are fully authorable too — each `series`/`line` binds its own OQL-view datasource + X/Y attributes; Pie/HeatMap bind at the widget level (`ValueAttribute:`, Pie needs `SeriesName:`). -See **[Custom & Pluggable Widgets → Charts](custom-widgets.md)** for the chart-type +See **[Custom & Pluggable Widgets → Charts](../custom-widgets/SKILL.md)** for the chart-type → id table, per-chart required-property gotchas (TimeSeries needs a datetime X, Bubble needs a size attribute), and the **CE0463 → `mxcli docker check`/`build`** step (these normalize widgets *and* preserve MPRv2 storage — never run bare @@ -334,7 +339,7 @@ mxcli -p baedemo.mpr -c "SHOW NAVIGATION MENU Responsive" # the menu tree Add or reorder items with `CREATE OR REPLACE NAVIGATION …` (full-replacement — dump the current profile first with `DESCRIBE NAVIGATION `, edit, re-apply). See -`manage-navigation.md` for the item syntax, home/login pages, and role-based homes. +`manage-navigation` for the item syntax, home/login pages, and role-based homes. ### Style the shell to match the design @@ -477,7 +482,7 @@ steps is usually plenty). ### Adding classes to an existing page -Use `alter page` to attach a class without rewriting the page (see `alter-page.md`): +Use `alter page` to attach a class without rewriting the page (see `alter-page`): ```sql alter page ResourceScheduling.Approvals { @@ -485,14 +490,14 @@ alter page ResourceScheduling.Approvals { } ``` -To apply the same class across many widgets/pages at once, see `bulk-widget-updates.md` +To apply the same class across many widgets/pages at once, see `bulk-widget-updates` (`update widgets ... dry run` first). --- ## ④ Build & Preview -SCSS is **not** live — you must compile before the theme shows. See `theme-styling.md`. +SCSS is **not** live — you must compile before the theme shows. See `theme-styling`. ```bash mxcli docker build -p baedemo.mpr # compiles SCSS into the deployment package (~55s) @@ -507,7 +512,7 @@ mxcli docker reload -p baedemo.mpr --css # pushes compiled CSS to browsers (in ## ⑤ Verify Against the Prototype Put the running screen next to the handoff screenshot and reconcile the diff — spacing, -colours, font, component shapes. The project already has Playwright wired in (`test-app.md`) +colours, font, component shapes. The project already has Playwright wired in (`test-app`) for screenshotting the running app. Iterate ②–④ per screen until it matches. --- @@ -564,9 +569,9 @@ settles it in one pass — see `.claude/skills/verify-in-runtime.md`. - **For bespoke tables, prefer a styled `listview`** over the `datagrid` pluggable widget — you control the full row markup, which a pixel-faithful design usually needs. - **Prefer typed `designproperties:` / Atlas classes over custom SCSS for anything Atlas - covers** (spacing, alignment, card/background, flex layout) — see `atlas-design.md`. Keys + covers** (spacing, alignment, card/background, flex layout) — see `atlas-design`. Keys and values are case-sensitive; `mxcli check -p` validates them (MDL-WIDGET11/12) and lists - the allowed values. `theme-styling.md` has the compilation/reload mechanics. + the allowed values. `theme-styling` has the compilation/reload mechanics. - **`alter styling` can't find widgets in MDL-builder-created pages** — apply classes via `Class:`/`DynamicClasses:` in `create page` / `alter page` instead. @@ -583,7 +588,7 @@ the fast index so a design migration doesn't rediscover them. `visible`, `dynamicclasses`, and inline-bracket XPath `where` — mxcli now **strips** stray identifier quotes, so a quoted attribute no longer breaks the build or makes an XPath `where` silently return 0 rows; still prefer the unquoted form there for readability. See - `write-microflows.md`. + `write-microflows`. - **Show a related (to-one) object's attributes** — two ways, both persist now: - A nested **"data from context" DataView** for a full read view of the referenced object; its children bind to the related entity: @@ -608,11 +613,11 @@ the fast index so a design migration doesn't rediscover them. SVG** container (or `HTMLElement`) for sparklines/trends — no datasource — and `ProgressCircle` (`type: expression`, `expressionCurrentValue: '$currentObject/Rate'`, min `'0'` / max `'100'`, `labelType: percentage`) for a single-value gauge. -- **Dashboard aggregates → OQL view entities** (`write-oql-queries.md`). A grouped enum column in +- **Dashboard aggregates → OQL view entities** (`write-oql-queries`). A grouped enum column in the view must be typed `enumeration(Module.Enum)`, not `string`, or you get CE6770. Test with `mxcli oql` first. - **Seed demo data via an after-startup microflow** guarded to run once (skip if data exists); it - must `return true`. Direct-SQL seeding doesn't apply to the default **HSQLDB**. See `demo-data.md`. + must `return true`. Direct-SQL seeding doesn't apply to the default **HSQLDB**. See `demo-data`. - **New entities / view entities need a runtime restart** (`docker up --detach --wait`) to register — a hot `reload` won't see them. Hot `reload` also occasionally crashes the runtime; if the app stops responding, `docker up --detach --wait` recovers it with data intact. diff --git a/.claude/skills/mendix/migrate-k2-nintex.md b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md similarity index 95% rename from .claude/skills/mendix/migrate-k2-nintex.md rename to .claude/skills/mendix/migrate-k2-nintex/SKILL.md index 36609ae33..aee2db8e0 100644 --- a/.claude/skills/mendix/migrate-k2-nintex.md +++ b/.claude/skills/mendix/migrate-k2-nintex/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-k2-nintex +description: "Assess and migrate K2 / Nintex K2 applications to Mendix — SmartObjects to domain models, SmartForms to pages, K2 workflows to microflows and Mendix workflows. Use when analysing or converting a K2 application." +--- + # K2/Nintex to Mendix Migration Skill This skill provides comprehensive guidance for assessing and migrating K2 (now Nintex K2) applications to Mendix using MDL (Mendix Definition Language). @@ -463,9 +468,9 @@ If you have a `.kspx` file, it can potentially be parsed (it's a ZIP-based forma ## Related Skills -- [/assess-migration](./assess-migration.md) - General migration assessment framework -- [/generate-domain-model](./generate-domain-model.md) - Creating entities and associations in MDL -- [/write-microflows](./write-microflows.md) - Implementing business logic in MDL -- [/create-page](./create-page.md) - Building pages in MDL -- [/manage-security](./manage-security.md) - Setting up roles and access rules -- [/organize-project](./organize-project.md) - Folder structure for the migrated project +- [/assess-migration](../assess-migration/SKILL.md) - General migration assessment framework +- [/generate-domain-model](../generate-domain-model/SKILL.md) - Creating entities and associations in MDL +- [/write-microflows](../write-microflows/SKILL.md) - Implementing business logic in MDL +- [/create-page](../create-page/SKILL.md) - Building pages in MDL +- [/manage-security](../manage-security/SKILL.md) - Setting up roles and access rules +- [/organize-project](../organize-project/SKILL.md) - Folder structure for the migrated project diff --git a/.claude/skills/mendix/migrate-oracle-forms.md b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md similarity index 95% rename from .claude/skills/mendix/migrate-oracle-forms.md rename to .claude/skills/mendix/migrate-oracle-forms/SKILL.md index e64d7b838..554c42976 100644 --- a/.claude/skills/mendix/migrate-oracle-forms.md +++ b/.claude/skills/mendix/migrate-oracle-forms/SKILL.md @@ -1,3 +1,8 @@ +--- +name: migrate-oracle-forms +description: "Assess and migrate Oracle Forms applications to Mendix — .fmb forms to pages, PL/SQL to microflows, and a staged migration strategy. Use when analysing or converting an Oracle Forms system." +--- + # Oracle Forms to Mendix Migration Skill This skill provides comprehensive guidance for migrating Oracle Forms applications to Mendix using MDL (Mendix Definition Language). @@ -63,7 +68,7 @@ MDL scripts execute statements sequentially. Items created in one statement can ```mdl -- check-skip: the PHASE 3 page block uses shorthand pseudo-syntax -- (layout/title/parameter/widgets, dataview source, INPUT ...) to sketch the --- migrated UI concept; it is not runnable MDL. See create-page.md / overview-pages.md. +-- migrated UI concept; it is not runnable MDL. See create-page / overview-pages. -- ============================================ -- PHASE 1: Domain Model (Entities & Associations) -- ============================================ @@ -316,7 +321,7 @@ datagrid source $OrderList ( **Oracle Forms Master-Detail → Mendix:** ```mdl -- check-skip: shorthand pseudo-syntax sketch of the migrated page, not runnable --- MDL. See create-page.md / overview-pages.md for real page syntax. +-- MDL. See create-page / overview-pages for real page syntax. create page MyModule.CustomerOrders layout Atlas_Default title 'Customer Orders' @@ -399,7 +404,7 @@ After migration: ## Related Skills -- [/write-microflows](./write-microflows.md) - Detailed microflow syntax +- [/write-microflows](../write-microflows/SKILL.md) - Detailed microflow syntax - [/create-crud](./create-crud.md) - Generate CRUD operations -- [/overview-pages](./overview-pages.md) - Page building patterns -- [/master-detail-pages](./master-detail-pages.md) - Master-detail layouts +- [/overview-pages](../overview-pages/SKILL.md) - Page building patterns +- [/master-detail-pages](../master-detail-pages/SKILL.md) - Master-detail layouts diff --git a/.claude/skills/mendix/mock-rest-apis.md b/.claude/skills/mendix/mock-rest-apis/SKILL.md similarity index 90% rename from .claude/skills/mendix/mock-rest-apis.md rename to .claude/skills/mendix/mock-rest-apis/SKILL.md index 0f99d25ec..6d67bb73f 100644 --- a/.claude/skills/mendix/mock-rest-apis.md +++ b/.claude/skills/mendix/mock-rest-apis/SKILL.md @@ -1,3 +1,8 @@ +--- +name: mock-rest-apis +description: "Stand up an HTTP endpoint you control instead of a live third-party API, and point the Mendix app at it — Prism from an OpenAPI contract, a constant swap, or a forward proxy. Use when building or debugging a REST integration without the real API, forcing a 404/500 through an error handler, or running offline or in CI." +--- + # Mock REST APIs Skill Use this skill when a REST integration needs an endpoint you control instead of a @@ -14,7 +19,7 @@ the BSON. A mock removes the variables that are not the bug. - Building a REST client or `REST CALL` microflow before (or without) real credentials - Reproducing a payload-shaped bug **deterministically** — a shape small enough to read, that behaves the same on every run - Exercising error paths: 404, 500, a timeout, a 401 from missing auth -- Verifying the app (`test-app.md`) or running a suite (`test-microflows.md`) offline or in CI +- Verifying the app (`test-app`) or running a suite (`test-microflows`) offline or in CI - Redirecting the outbound calls of an app whose model you must not edit ## Two separate problems @@ -157,7 +162,7 @@ Two things to know before committing to this route: For **consumed OData** services specifically there is a fourth route that needs no JVM flags: `System.ConsumedODataConfiguration` carries `ProxyConfiguration`, -`ProxyHost` and `ProxyPort` as data (see `system-module.md`), so the proxy can be +`ProxyHost` and `ProxyPort` as data (see `system-module`), so the proxy can be set per service at runtime. ## Verify the mock before blaming Mendix @@ -187,8 +192,8 @@ Then, and only then, run the microflow and check the payload actually reached it ## Related Skills -- `rest-client.md` — the three ways to call a REST API; where the contract goes once you have one -- `rest-call-from-json.md` — JSON structure → entities → import mapping → `REST CALL` -- `test-app.md` — browser verification; a REST app's prerequisite is a reachable endpoint -- `test-microflows.md` — running a suite; `--constant` points it at the mock -- `run-local.md` — `mxcli run --local`, the warm loop the mock plugs into +- `rest-client` — the three ways to call a REST API; where the contract goes once you have one +- `rest-call-from-json` — JSON structure → entities → import mapping → `REST CALL` +- `test-app` — browser verification; a REST app's prerequisite is a reachable endpoint +- `test-microflows` — running a suite; `--constant` points it at the mock +- `run-local` — `mxcli run --local`, the warm loop the mock plugs into diff --git a/.claude/skills/mendix/odata-data-sharing.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md similarity index 99% rename from .claude/skills/mendix/odata-data-sharing.md rename to .claude/skills/mendix/odata-data-sharing/SKILL.md index 8cef10c17..006ff9b75 100644 --- a/.claude/skills/mendix/odata-data-sharing.md +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -1,3 +1,8 @@ +--- +name: odata-data-sharing +description: "Share data between Mendix apps over OData — published services, view entities as an abstraction layer, consumed clients and external entities. Use when exposing data from one app to another, consuming another app's OData service, or refreshing a cached contract." +--- + # OData Data Sharing Between Mendix Apps This skill covers how to use OData services to share data between Mendix applications, with emphasis on using view entities as an abstraction layer to decouple the API contract from the internal domain model. diff --git a/.claude/skills/mendix/organize-project.md b/.claude/skills/mendix/organize-project/SKILL.md similarity index 97% rename from .claude/skills/mendix/organize-project.md rename to .claude/skills/mendix/organize-project/SKILL.md index f1d989863..9e873c8b0 100644 --- a/.claude/skills/mendix/organize-project.md +++ b/.claude/skills/mendix/organize-project/SKILL.md @@ -1,3 +1,8 @@ +--- +name: organize-project +description: "Organise documents into folders and move them between folders and modules with MOVE. Use when a module has grown unstructured, when restructuring a project, or when a document is in the wrong place." +--- + # Project Organization: Folders and Moving Documents This skill covers organizing Mendix project documents (pages, microflows, snippets, nanoflows) into folders and moving them between folders and modules. diff --git a/.claude/skills/mendix/overview-pages.md b/.claude/skills/mendix/overview-pages/SKILL.md similarity index 96% rename from .claude/skills/mendix/overview-pages.md rename to .claude/skills/mendix/overview-pages/SKILL.md index f906412c4..dbfc4b484 100644 --- a/.claude/skills/mendix/overview-pages.md +++ b/.claude/skills/mendix/overview-pages/SKILL.md @@ -1,3 +1,8 @@ +--- +name: overview-pages +description: "The CRUD overview page pattern in MDL — a navigation snippet, a list page and a new/edit page wired together. Use when building the standard list-plus-edit screens for an entity." +--- + # Overview Pages - CRUD Page Pattern ## Overview @@ -613,14 +618,14 @@ create or modify snippet Module.NavigationMenu - **Do not use `create or replace snippet`** — that deletes the placeholder and creates a fresh UUID, silently breaking every page that references the old one - Page references in the final snippet resolve correctly because pages already exist -See [Resolve Forward References](./resolve-forward-references.md) for the full pattern including page→page and microflow→page cases, declaration ordering rules, and the choice between `CREATE OR MODIFY` and `ALTER SNIPPET`. +See [Resolve Forward References](../resolve-forward-references/SKILL.md) for the full pattern including page→page and microflow→page cases, declaration ordering rules, and the choice between `CREATE OR MODIFY` and `ALTER SNIPPET`. ## Related Skills -- [Create Page](./create-page.md) - Basic page creation syntax -- [ALTER PAGE/SNIPPET](./alter-page.md) - Modify existing pages/snippets in-place (SET, INSERT, DROP, REPLACE) -- [Master-Detail Pages](./master-detail-pages.md) - Selection binding pattern -- [Resolve Forward References](./resolve-forward-references.md) - Placeholder pattern, declaration ordering +- [Create Page](../create-page/SKILL.md) - Basic page creation syntax +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) - Modify existing pages/snippets in-place (SET, INSERT, DROP, REPLACE) +- [Master-Detail Pages](../master-detail-pages/SKILL.md) - Selection binding pattern +- [Resolve Forward References](../resolve-forward-references/SKILL.md) - Placeholder pattern, declaration ordering ## Snippet Commands Reference diff --git a/.claude/skills/mendix/patterns-crud.md b/.claude/skills/mendix/patterns-crud/SKILL.md similarity index 95% rename from .claude/skills/mendix/patterns-crud.md rename to .claude/skills/mendix/patterns-crud/SKILL.md index 72caf1b23..28d5e23b2 100644 --- a/.claude/skills/mendix/patterns-crud.md +++ b/.claude/skills/mendix/patterns-crud/SKILL.md @@ -1,3 +1,8 @@ +--- +name: patterns-crud +description: "Standard Create/Read/Update/Delete microflow patterns with the naming conventions that go with them. Use when writing the action microflows behind page buttons rather than looking up individual syntax." +--- + # CRUD Action Patterns Standard patterns for Create, Read, Update, Delete operations on entities. diff --git a/.claude/skills/mendix/patterns-data-processing.md b/.claude/skills/mendix/patterns-data-processing/SKILL.md similarity index 96% rename from .claude/skills/mendix/patterns-data-processing.md rename to .claude/skills/mendix/patterns-data-processing/SKILL.md index 88b015521..ac6fa5347 100644 --- a/.claude/skills/mendix/patterns-data-processing.md +++ b/.claude/skills/mendix/patterns-data-processing/SKILL.md @@ -1,3 +1,8 @@ +--- +name: patterns-data-processing +description: "Patterns for loops, aggregates, batch processing and list transformation — including which retrieve source is legal where, and why nested loops are the wrong tool. Use when a microflow processes a list, merges data, or does anything in bulk." +--- + # Data Processing Patterns Patterns for loops, aggregates, batch processing, and data transformation. diff --git a/.claude/skills/mendix/project-settings.md b/.claude/skills/mendix/project-settings/SKILL.md similarity index 96% rename from .claude/skills/mendix/project-settings.md rename to .claude/skills/mendix/project-settings/SKILL.md index 765836316..befd9c30f 100644 --- a/.claude/skills/mendix/project-settings.md +++ b/.claude/skills/mendix/project-settings/SKILL.md @@ -1,3 +1,8 @@ +--- +name: project-settings +description: "Read and change project settings in MDL — database configuration, constant overrides, after-startup and before-shutdown microflows, and the rest of Studio Pro's Settings dialog. Use when configuring a deployment or wiring a startup microflow." +--- + # Project Settings ## When to Use This Skill diff --git a/.claude/skills/mendix/regular-expressions.md b/.claude/skills/mendix/regular-expressions/SKILL.md similarity index 93% rename from .claude/skills/mendix/regular-expressions.md rename to .claude/skills/mendix/regular-expressions/SKILL.md index cdc4aae5e..160a88dcb 100644 --- a/.claude/skills/mendix/regular-expressions.md +++ b/.claude/skills/mendix/regular-expressions/SKILL.md @@ -1,3 +1,8 @@ +--- +name: regular-expressions +description: "Create and manage named regular-expression documents and bind them to attribute validation rules. Use when adding an email, phone or identifier pattern, changing a shared pattern, or working out why a regex cannot attach directly to an attribute." +--- + # Regular Expressions ## When to Use This Skill @@ -142,4 +147,4 @@ select QualifiedName, Expression from CATALOG.REGULAR_EXPRESSIONS; - `mxcli syntax regular-expression` — full syntax reference - `mxcli syntax validation-rule` — binding a pattern or a range to an attribute -- `mdl-entities.md` — attributes and validation +- `mdl-entities` — attributes and validation diff --git a/.claude/skills/mendix/resolve-forward-references.md b/.claude/skills/mendix/resolve-forward-references/SKILL.md similarity index 94% rename from .claude/skills/mendix/resolve-forward-references.md rename to .claude/skills/mendix/resolve-forward-references/SKILL.md index d5a3cc4d5..6fa7a6580 100644 --- a/.claude/skills/mendix/resolve-forward-references.md +++ b/.claude/skills/mendix/resolve-forward-references/SKILL.md @@ -1,3 +1,8 @@ +--- +name: resolve-forward-references +description: "Order MDL statements so that every reference resolves — execution is sequential and immediate, so a document must exist before anything points at it. Use when a script fails on a reference to something defined later in the same file." +--- + # Resolving Forward References in MDL Scripts ## Why Forward References Fail @@ -323,6 +328,6 @@ alter navigation Responsive ## Related Skills -- [Create Page](./create-page.md) — Full page syntax reference -- [Overview Pages](./overview-pages.md) — Overview + NewEdit page patterns -- [ALTER PAGE/SNIPPET](./alter-page.md) — In-place snippet modification +- [Create Page](../create-page/SKILL.md) — Full page syntax reference +- [Overview Pages](../overview-pages/SKILL.md) — Overview + NewEdit page patterns +- [ALTER PAGE/SNIPPET](../alter-page/SKILL.md) — In-place snippet modification diff --git a/.claude/skills/mendix/rest-call-from-json.md b/.claude/skills/mendix/rest-call-from-json/SKILL.md similarity index 95% rename from .claude/skills/mendix/rest-call-from-json.md rename to .claude/skills/mendix/rest-call-from-json/SKILL.md index 029f6d95a..4b03aadb8 100644 --- a/.claude/skills/mendix/rest-call-from-json.md +++ b/.claude/skills/mendix/rest-call-from-json/SKILL.md @@ -1,3 +1,8 @@ +--- +name: rest-call-from-json +description: "Generate the whole integration stack from a JSON payload: JSON structure, non-persistent entities, import mapping, and the REST CALL microflow. Use when starting from an example response and needing everything between it and a working microflow." +--- + # REST Call from JSON Payload — End-to-End Skill Use this skill to generate the full stack of Mendix integration artifacts from a JSON payload: @@ -5,7 +10,7 @@ JSON Structure → Non-persistent entities → Import Mapping → microflow. > **Two approaches**: This skill uses the **inline REST CALL** approach (good for one-off calls > and quick prototyping). For structured APIs with reusable operations, use the **REST Client** -> approach instead — see [rest-client.md](rest-client.md) for `create rest client` + `send rest request` +> approach instead — see [rest-client](../rest-client/SKILL.md) for `create rest client` + `send rest request` > + optional `transform` with JSLT data transformers. ## Overview — Four Steps @@ -72,7 +77,7 @@ create association Module.MyRootObject_MyNestedObject ## Step 3 — Import Mapping -> **Full reference**: See [json-structures-and-mappings.md](json-structures-and-mappings.md) for complete import/export mapping syntax, domain model patterns, and common mistakes. +> **Full reference**: See [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) for complete import/export mapping syntax, domain model patterns, and common mistakes. ```sql create import mapping Module.IMM_MyMapping diff --git a/.claude/skills/mendix/rest-client.md b/.claude/skills/mendix/rest-client/SKILL.md similarity index 96% rename from .claude/skills/mendix/rest-client.md rename to .claude/skills/mendix/rest-client/SKILL.md index 81d3e5e40..933a404e3 100644 --- a/.claude/skills/mendix/rest-client.md +++ b/.claude/skills/mendix/rest-client/SKILL.md @@ -1,3 +1,8 @@ +--- +name: rest-client +description: "Call external REST APIs from Mendix — the three approaches (inline REST CALL, consumed REST client document, generated from OpenAPI) and how to choose. Use when integrating with any external REST API." +--- + # REST Integration Skill Use this skill when integrating with external REST APIs from Mendix. @@ -14,7 +19,7 @@ Mendix offers three ways to call REST APIs from microflows. Choose based on the Both REST Client approaches can be combined with **Data Transformers** (Mendix 11.9+) and **Import/Export Mappings** to map between JSON and entities. -No API to call against yet — or one you would rather not depend on while building? [mock-rest-apis.md](mock-rest-apis.md) covers standing up an endpoint you control and pointing the app at it. +No API to call against yet — or one you would rather not depend on while building? [mock-rest-apis](../mock-rest-apis/SKILL.md) covers standing up an endpoint you control and pointing the app at it. --- @@ -53,7 +58,7 @@ which fails at call time rather than at import time. Set `BaseUrl` explicitly in When provided, `BaseUrl` overrides the spec's value — useful when the spec points at production but you need to import against staging, a different version, or a local mock -(see [mock-rest-apis.md](mock-rest-apis.md)). +(see [mock-rest-apis](../mock-rest-apis/SKILL.md)). **Preview without writing:** ```sql @@ -299,7 +304,7 @@ drop data transformer Module.Name; ## JSON Structures & Mappings -See [json-structures-and-mappings.md](json-structures-and-mappings.md) for full reference. Quick summary: +See [json-structures-and-mappings](../json-structures-and-mappings/SKILL.md) for full reference. Quick summary: ```sql -- JSON structure from snippet diff --git a/.claude/skills/mendix/run-app.md b/.claude/skills/mendix/run-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/run-app.md rename to .claude/skills/mendix/run-app/SKILL.md index df4fe6002..8009a9b25 100644 --- a/.claude/skills/mendix/run-app.md +++ b/.claude/skills/mendix/run-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: run-app +description: "Build and start the Mendix app in Docker, and restart it after changes. Use when asked to run, launch or restart the app in a container. For the fast inner loop prefer run-local." +--- + # Run App Skill This skill builds and starts the Mendix application in Docker. diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local/SKILL.md similarity index 98% rename from .claude/skills/mendix/run-local.md rename to .claude/skills/mendix/run-local/SKILL.md index 0194eaca1..f2b91b3e0 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local/SKILL.md @@ -1,3 +1,8 @@ +--- +name: run-local +description: "The warm Docker-free dev loop, `mxcli run --local` — model changes live in about a second, with watch, screenshots, metrics, tracing and external preview. Use for the fastest edit-to-running-app cycle, especially when driving the model programmatically with MDL." +--- + # Warm Local Dev Loop — `mxcli run --local` ## Overview @@ -205,8 +210,8 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr | `--screenshot-path` / `--screenshot-url` | `.mxcli/run-local.png` / app root | Screenshot output / page (URL or `/path`) | | `--screenshot-user` / `--screenshot-password` | — | Log in once, reuse session (pages behind login) | | `--runtime-log` | `.mxcli/runtime.log` | Runtime log file: JVM stdout/stderr **and** the application log (microflow `LOG` output + server stack traces, via an attached file log subscriber). `-` disables. | -| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows.md`. | -| `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows.md`). No breakpoints = no behaviour change; disabled on shutdown. | +| `--test-endpoint` | off | Host mxcli's token-guarded test endpoint so `mxcli test … --attach` can run a suite against this app with no boot of its own. Installed **before** the boot (the handler registers from after-startup), your own after-startup microflow is chained not displaced, and both are removed on exit. See `test-microflows`. | +| `--debug` | off | Enable the microflow debugger at boot + start a session, so `mxcli debug break/paused/…` works from another terminal (see `debug-microflows`). No breakpoints = no behaviour change; disabled on shutdown. | | `--debug-pass` | `mxdebug` | Debugger password when `--debug` is set | | `--metrics` | off | Register a Prometheus meter registry at boot; the runtime serves metrics at `http://127.0.0.1:/prometheus` | | `--trace` | off | Enable OpenTelemetry tracing (bundled agent, console exporter → the runtime log) with default span filters | diff --git a/.claude/skills/mendix/runtime-admin-api.md b/.claude/skills/mendix/runtime-admin-api/SKILL.md similarity index 92% rename from .claude/skills/mendix/runtime-admin-api.md rename to .claude/skills/mendix/runtime-admin-api/SKILL.md index 383a874ed..c98be9078 100644 --- a/.claude/skills/mendix/runtime-admin-api.md +++ b/.claude/skills/mendix/runtime-admin-api/SKILL.md @@ -1,3 +1,8 @@ +--- +name: runtime-admin-api +description: "Call the Mendix M2EE admin API on port 8090 directly — OQL, runtime info, and the rest, with curl examples. Use when querying a running app from a script, or when debugging connectivity to the admin port." +--- + # Runtime Admin API Skill This skill documents the Mendix M2EE admin API exposed on port 8090, including how to call it directly with curl. @@ -198,7 +203,7 @@ The build patch `admin.addresses = ["*"]` is applied automatically by `mxcli doc ## Related Skills -- [/run-app](./run-app.md) — Start the Mendix app in Docker -- [/write-oql-queries](./write-oql-queries.md) — OQL syntax reference -- [/docker-workflow](./docker-workflow.md) — Full Docker build/run workflow -- [/database-connections](./database-connections.md) — Direct PostgreSQL access +- [/run-app](../run-app/SKILL.md) — Start the Mendix app in Docker +- [/write-oql-queries](../write-oql-queries/SKILL.md) — OQL syntax reference +- [/docker-workflow](../docker-workflow/SKILL.md) — Full Docker build/run workflow +- [/database-connections](../database-connections/SKILL.md) — Direct PostgreSQL access diff --git a/.claude/skills/mendix/scheduled-events-and-queues.md b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md similarity index 95% rename from .claude/skills/mendix/scheduled-events-and-queues.md rename to .claude/skills/mendix/scheduled-events-and-queues/SKILL.md index 65f3deceb..03976820a 100644 --- a/.claude/skills/mendix/scheduled-events-and-queues.md +++ b/.claude/skills/mendix/scheduled-events-and-queues/SKILL.md @@ -1,3 +1,8 @@ +--- +name: scheduled-events-and-queues +description: "Scheduled events (Mendix's cron) and task queues in MDL — the eight repeat variants and the fields each takes, and what a queue does and does not throttle. Use when running a microflow on a schedule, or bounding how many background tasks run at once." +--- + # Scheduled Events and Task Queues ## When to Use This Skill @@ -235,7 +240,7 @@ create queue Ops.Q_Imports folder 'Private/Queues' ( Parallelism: 3 ); ``` On `create or modify` the clause moves an existing document; omitting it leaves -placement alone. See `organize-project.md`. +placement alone. See `organize-project`. ## Validation Checklist @@ -277,5 +282,5 @@ Starlark lint rules can iterate both: `scheduled_events()` yields ## Related - `mxcli syntax scheduled-event`, `mxcli syntax queue` — full syntax reference -- `write-microflows.md` — writing the microflow the event calls -- `project-settings.md` — after-startup / before-shutdown microflows +- `write-microflows` — writing the microflow the event calls +- `project-settings` — after-startup / before-shutdown microflows diff --git a/.claude/skills/mendix/system-module.md b/.claude/skills/mendix/system-module/SKILL.md similarity index 98% rename from .claude/skills/mendix/system-module.md rename to .claude/skills/mendix/system-module/SKILL.md index 39919a45a..043556891 100644 --- a/.claude/skills/mendix/system-module.md +++ b/.claude/skills/mendix/system-module/SKILL.md @@ -1,3 +1,8 @@ +--- +name: system-module +description: "Reference for the built-in System module — User, FileDocument, Image, workflow and queue entities, and the associations you are allowed to make to them. Use when linking a record to System.User, handling file uploads, or working with workflow entities." +--- + # Mendix System Module Reference The `System` module is a built-in Mendix module present in every application. It provides core entities for user management, file handling, workflows, task queues, HTTP services, and more. These entities are not defined in the application's domain model but are available for use in microflows, pages, associations, and Java actions. diff --git a/.claude/skills/mendix/test-app.md b/.claude/skills/mendix/test-app/SKILL.md similarity index 95% rename from .claude/skills/mendix/test-app.md rename to .claude/skills/mendix/test-app/SKILL.md index 4e1ecb1c5..149c3662a 100644 --- a/.claude/skills/mendix/test-app.md +++ b/.claude/skills/mendix/test-app/SKILL.md @@ -1,3 +1,8 @@ +--- +name: test-app +description: "Verify a running Mendix app in a browser with Playwright, with OQL for data assertions. Use when asked to test or validate the app end to end, or to confirm that generated pages actually render." +--- + # Test App Skill This skill guides you through verifying a running Mendix application using playwright-cli for browser automation and mxcli oql for data assertions. @@ -21,7 +26,7 @@ The devcontainer created by `mxcli init` installs: - **Chromium (headless shell)** — installed via `@playwright/cli`'s **bundled** `playwright-core`, into a shared `PLAYWRIGHT_BROWSERS_PATH`, and exposed at the stable path `/usr/local/bin/mx-headless-shell`. The generated `.playwright/cli.config.json` pins `executablePath` to that symlink. - **Docker-in-Docker** — Mendix + PostgreSQL running via `mxcli docker run` -If the app calls an external REST API, that endpoint is a prerequisite too — a verification run that depends on a live third party is not repeatable. See [mock-rest-apis.md](mock-rest-apis.md). +If the app calls an external REST API, that endpoint is a prerequisite too — a verification run that depends on a live third party is not repeatable. See [mock-rest-apis](../mock-rest-apis/SKILL.md). The app must be running before verification: @@ -453,9 +458,9 @@ playwright-cli snapshot ## Related Skills -- [test-microflows.md](./test-microflows.md) - **MDL microflow tests** (business logic, no browser needed) -- [/run-app](./run-app.md) - Build and start the Mendix app in Docker -- [/docker-workflow](./docker-workflow.md) - Full Docker workflow reference -- [/demo-data](./demo-data.md) - Seed test data into PostgreSQL -- [/create-page](./create-page.md) - Page creation patterns (widget names for selectors) -- [/write-microflows](./write-microflows.md) - Microflow patterns (data persistence logic) +- [test-microflows](../test-microflows/SKILL.md) - **MDL microflow tests** (business logic, no browser needed) +- [/run-app](../run-app/SKILL.md) - Build and start the Mendix app in Docker +- [/docker-workflow](../docker-workflow/SKILL.md) - Full Docker workflow reference +- [/demo-data](../demo-data/SKILL.md) - Seed test data into PostgreSQL +- [/create-page](../create-page/SKILL.md) - Page creation patterns (widget names for selectors) +- [/write-microflows](../write-microflows/SKILL.md) - Microflow patterns (data persistence logic) diff --git a/.claude/skills/mendix/test-microflows.md b/.claude/skills/mendix/test-microflows/SKILL.md similarity index 97% rename from .claude/skills/mendix/test-microflows.md rename to .claude/skills/mendix/test-microflows/SKILL.md index 902984777..06d443854 100644 --- a/.claude/skills/mendix/test-microflows.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: test-microflows +description: "Write and run MDL-based microflow tests with `mxcli test` — annotations, file formats, and the warm local test loop. Use when testing microflow logic rather than the UI: return values, entity changes, control flow." +--- + # Test Microflows Skill This skill guides you through writing and running MDL-based microflow tests using `mxcli test`. @@ -767,7 +772,7 @@ The JUnit XML works with GitHub Actions, Jenkins, Azure DevOps, GitLab CI, etc. ## Related Skills -- [test-app.md](test-app.md) — Playwright UI tests (pages, widgets, browser interactions) -- [write-microflows.md](write-microflows.md) — Microflow syntax reference -- [docker-workflow.md](docker-workflow.md) — Docker build and runtime workflow -- [verify-with-oql.md](verify-with-oql.md) — OQL queries for data verification +- [test-app](../test-app/SKILL.md) — Playwright UI tests (pages, widgets, browser interactions) +- [write-microflows](../write-microflows/SKILL.md) — Microflow syntax reference +- [docker-workflow](../docker-workflow/SKILL.md) — Docker build and runtime workflow +- [verify-with-oql](../verify-with-oql/SKILL.md) — OQL queries for data verification diff --git a/.claude/skills/mendix/theme-styling.md b/.claude/skills/mendix/theme-styling/SKILL.md similarity index 98% rename from .claude/skills/mendix/theme-styling.md rename to .claude/skills/mendix/theme-styling/SKILL.md index 51c778282..f1092144d 100644 --- a/.claude/skills/mendix/theme-styling.md +++ b/.claude/skills/mendix/theme-styling/SKILL.md @@ -1,3 +1,8 @@ +--- +name: theme-styling +description: "The SCSS workflow and its traps — where styling actually compiles, custom-variables.scss, themesource directories, hot reload, and design-property errors. Use when writing or debugging SCSS, or when styling silently fails to appear." +--- + # Theme & Styling — SCSS Workflow and Caveats ## When to Use This Skill diff --git a/.claude/skills/mendix/validation-microflows.md b/.claude/skills/mendix/validation-microflows/SKILL.md similarity index 97% rename from .claude/skills/mendix/validation-microflows.md rename to .claude/skills/mendix/validation-microflows/SKILL.md index 617b43804..5c6e7eab9 100644 --- a/.claude/skills/mendix/validation-microflows.md +++ b/.claude/skills/mendix/validation-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: validation-microflows +description: "Validation microflows for new/edit pages — attribute checks, feedback messages, and conditional validation chains. Use when a form needs input validation with messages shown against the fields." +--- + # Validation Microflows Skill This skill provides guidance for creating validation microflows in MDL that validate user input on NewEdit pages and provide feedback to users. diff --git a/.claude/skills/mendix/verify-with-oql.md b/.claude/skills/mendix/verify-with-oql/SKILL.md similarity index 89% rename from .claude/skills/mendix/verify-with-oql.md rename to .claude/skills/mendix/verify-with-oql/SKILL.md index b4aa9aa95..97d9c8ae0 100644 --- a/.claude/skills/mendix/verify-with-oql.md +++ b/.claude/skills/mendix/verify-with-oql/SKILL.md @@ -1,3 +1,8 @@ +--- +name: verify-with-oql +description: "Verify microflow side effects and data changes with OQL against a running app. Use after executing changes to confirm data was created, updated or deleted as expected, or to back a browser test with a data assertion." +--- + # Verify with OQL Skill This skill documents how to verify microflow side effects and data changes using OQL queries against a running Mendix app. @@ -170,7 +175,7 @@ This loop avoids container restarts and database resets, making each iteration t - **OQL is read-only** — `mxcli oql` uses the `preview_execute_oql` action which cannot modify data - **OQL won't see rows you wrote directly into Postgres until the runtime reloads.** `mxcli oql` executes through the *running app's* query layer (not a separate DB), so rows seeded with direct - SQL `INSERT`s (see [demo-data.md](./demo-data.md)) are invisible until the runtime re-reads them. + SQL `INSERT`s (see [demo-data](../demo-data/SKILL.md)) are invisible until the runtime re-reads them. After seeding, run `mxcli docker reload` (or restart the app) before trusting an OQL `count(*)` of `0`. To confirm the rows landed *before* a reload, query Postgres directly (`mxcli sql …`). - **Check before and after** — query the state before triggering an action to establish a baseline @@ -183,7 +188,7 @@ This loop avoids container restarts and database resets, making each iteration t ## Related Skills -- [/test-app](./test-app.md) — Full Playwright test patterns and setup -- [/runtime-admin-api](./runtime-admin-api.md) — Admin API details and curl examples -- [/docker-workflow](./docker-workflow.md) — Build, run, and hot reload workflow -- [/write-oql-queries](./write-oql-queries.md) — OQL syntax reference +- [/test-app](../test-app/SKILL.md) — Full Playwright test patterns and setup +- [/runtime-admin-api](../runtime-admin-api/SKILL.md) — Admin API details and curl examples +- [/docker-workflow](../docker-workflow/SKILL.md) — Build, run, and hot reload workflow +- [/write-oql-queries](../write-oql-queries/SKILL.md) — OQL syntax reference diff --git a/.claude/skills/mendix/write-lint-rules.md b/.claude/skills/mendix/write-lint-rules/SKILL.md similarity index 99% rename from .claude/skills/mendix/write-lint-rules.md rename to .claude/skills/mendix/write-lint-rules/SKILL.md index 4692bfbee..99eccf45b 100644 --- a/.claude/skills/mendix/write-lint-rules.md +++ b/.claude/skills/mendix/write-lint-rules/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-lint-rules +description: "Write custom Starlark lint rules in .claude/lint-rules/ that run beside the built-ins under `mxcli lint`. Use when a project convention should be enforced automatically." +--- + # Writing Custom Starlark Lint Rules Custom lint rules are written in Starlark (a Python-like language) and placed in `.claude/lint-rules/` as `.star` files. They run alongside the built-in rules when `mxcli lint -p app.mpr` is executed. diff --git a/.claude/skills/mendix/write-microflows.md b/.claude/skills/mendix/write-microflows/SKILL.md similarity index 99% rename from .claude/skills/mendix/write-microflows.md rename to .claude/skills/mendix/write-microflows/SKILL.md index 6b536b2a0..04f113413 100644 --- a/.claude/skills/mendix/write-microflows.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-microflows +description: "Microflow syntax reference in MDL — every activity type, control flow, expressions, and the mistakes that fail `mxcli check`. Use before writing any CREATE MICROFLOW, and when debugging a microflow syntax error." +--- + # Mendix Microflow Skill This skill provides comprehensive guidance for writing Mendix microflows in MDL (Mendix Definition Language) syntax. @@ -10,7 +15,7 @@ Use this skill when: - Converting Studio Pro microflows to MDL - Understanding microflow control flow and structure -If you're not sure whether the logic belongs in a microflow or a nanoflow, read the next section first. The mirror lives in [write-nanoflows.md](./write-nanoflows.md) — keep both copies in sync. +If you're not sure whether the logic belongs in a microflow or a nanoflow, read the next section first. The mirror lives in [write-nanoflows](../write-nanoflows/SKILL.md) — keep both copies in sync. ## When to Use a Microflow vs a Nanoflow @@ -46,7 +51,7 @@ If you're not sure whether the logic belongs in a microflow or a nanoflow, read | **Offline** | Not available | Available | | **Binary return type** | Supported | Not supported | -For nanoflow-specific authoring guidance, see [write-nanoflows.md](./write-nanoflows.md). +For nanoflow-specific authoring guidance, see [write-nanoflows](../write-nanoflows/SKILL.md). ## Microflow Structure @@ -835,7 +840,7 @@ commit $Product with events refresh; > alter entity Sales.Order > add event handler on before commit call Sales.ACT_ValidateOrder($currentObject) raise error; > ``` -> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model.md](generate-domain-model.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) +> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model](../generate-domain-model/SKILL.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) ## List Operations @@ -1202,7 +1207,7 @@ call java action Module.RefreshData(Url = $Url) in queue Module.RefreshQueue; (Parallelism: 2)`), and a queued **Java action must `returns void`** or the build fails with CE7038. Rewriting a microflow that has a queued call must restate the `in queue` clause; a rewrite that omits it is refused rather than silently -dropping the binding. See `.claude/skills/mendix/scheduled-events-and-queues.md`. +dropping the binding. See `.claude/skills/mendix/scheduled-events-and-queues`. ### ❌ INCORRECT Syntax diff --git a/.claude/skills/mendix/write-nanoflows.md b/.claude/skills/mendix/write-nanoflows/SKILL.md similarity index 98% rename from .claude/skills/mendix/write-nanoflows.md rename to .claude/skills/mendix/write-nanoflows/SKILL.md index 3ed54310f..88bb2af38 100644 --- a/.claude/skills/mendix/write-nanoflows.md +++ b/.claude/skills/mendix/write-nanoflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-nanoflows +description: "Nanoflow syntax in MDL — shared with microflows, but client-side and restricted. Use before writing any CREATE NANOFLOW, when a nanoflow validation error needs explaining, or when deciding between a nanoflow and a microflow." +--- + # Mendix Nanoflow Skill This skill provides guidance for writing Mendix nanoflows in MDL syntax. Nanoflows share syntax with microflows but execute client-side with restricted capabilities. @@ -10,7 +15,7 @@ Use this skill when: - Understanding nanoflow restrictions vs microflows - Building mobile or offline-capable features -If you're not sure whether the logic belongs in a nanoflow or a microflow, read the next section first. The mirror lives in [write-microflows.md](./write-microflows.md) — keep both copies in sync. +If you're not sure whether the logic belongs in a nanoflow or a microflow, read the next section first. The mirror lives in [write-microflows](../write-microflows/SKILL.md) — keep both copies in sync. ## When to Use a Nanoflow vs a Microflow diff --git a/.claude/skills/mendix/write-oql-queries.md b/.claude/skills/mendix/write-oql-queries/SKILL.md similarity index 99% rename from .claude/skills/mendix/write-oql-queries.md rename to .claude/skills/mendix/write-oql-queries/SKILL.md index 30da3732b..d15274719 100644 --- a/.claude/skills/mendix/write-oql-queries.md +++ b/.claude/skills/mendix/write-oql-queries/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-oql-queries +description: "Write OQL for Mendix VIEW entities — joins, aggregates, calculated fields, and the syntax the runtime actually accepts. Use when creating a VIEW entity or building a report or analytics query." +--- + # Skill: Write OQL Queries for Mendix VIEW Entities ## Purpose diff --git a/.claude/skills/mendix/write-rules.md b/.claude/skills/mendix/write-rules/SKILL.md similarity index 91% rename from .claude/skills/mendix/write-rules.md rename to .claude/skills/mendix/write-rules/SKILL.md index d8ab4b0c1..0a5566949 100644 --- a/.claude/skills/mendix/write-rules.md +++ b/.claude/skills/mendix/write-rules/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-rules +description: "Write Mendix rules — a special kind of microflow returning a Boolean or enumeration, callable only from a decision. Use when writing CREATE RULE, or deciding whether logic belongs in a rule or a microflow." +--- + # Mendix Rule Skill Guidance for writing Mendix **rules** in MDL. Mendix's own reference calls a rule @@ -10,8 +15,8 @@ returns a Boolean or an enumeration, and it can only be used from a decision. - Deciding whether logic belongs in a rule or a microflow - Understanding what `mxcli check` refuses in a rule body and why -The mirrors are [write-microflows.md](./write-microflows.md) and -[write-nanoflows.md](./write-nanoflows.md) — a rule is the third flavour and +The mirrors are [write-microflows](../write-microflows/SKILL.md) and +[write-nanoflows](../write-nanoflows/SKILL.md) — a rule is the third flavour and shares their body syntax exactly. ## When to Use a Rule vs a Microflow diff --git a/.claude/skills/mendix/write-workflows.md b/.claude/skills/mendix/write-workflows/SKILL.md similarity index 93% rename from .claude/skills/mendix/write-workflows.md rename to .claude/skills/mendix/write-workflows/SKILL.md index 66d8e8b66..71c41ddaa 100644 --- a/.claude/skills/mendix/write-workflows.md +++ b/.claude/skills/mendix/write-workflows/SKILL.md @@ -1,3 +1,8 @@ +--- +name: write-workflows +description: "Author Mendix workflows in MDL — user tasks, decisions, parallel splits, jumps, waits and boundary events, with CREATE, ALTER and DROP. Use when building a business process with human steps, timers or parallel branches." +--- + # Mendix Workflows Skill Guidance for **authoring** workflows in Mendix projects with MDL — not just @@ -210,7 +215,7 @@ bodies empty. return nothing — the System module's **enumerations** are not in the project file, so mxcli cannot resolve them. Constrain on an attribute instead (`[EndTime = empty]` selects open tasks) rather than naming a System enum value. System **entities** are -documented in `system-module.md`. +documented in `system-module`. ## Platform rules @@ -236,11 +241,11 @@ hand-rolling admin-API calls: | To see | Read | |---|---| -| Live instances and open tasks (OQL against the running app) | [`verify-with-oql.md`](verify-with-oql.md), [`write-oql-queries.md`](write-oql-queries.md) | -| The exception that stopped an instance | [`analyze-runtime.md`](analyze-runtime.md) — `run --local` tees the runtime log to `/.mxcli/runtime.log` | -| `System.Workflow` / `System.WorkflowUserTask` / `System.WorkflowDefinition` shapes | [`system-module.md`](system-module.md) | -| Driving a task end to end and asserting the result | [`test-app.md`](test-app.md), [`run-local.md`](run-local.md) | -| Raw admin API, incl. `POST /dev/preview_execute_oql` | [`runtime-admin-api.md`](runtime-admin-api.md) | +| Live instances and open tasks (OQL against the running app) | [`verify-with-oql`](../verify-with-oql/SKILL.md), [`write-oql-queries`](../write-oql-queries/SKILL.md) | +| The exception that stopped an instance | [`analyze-runtime`](../analyze-runtime/SKILL.md) — `run --local` tees the runtime log to `/.mxcli/runtime.log` | +| `System.Workflow` / `System.WorkflowUserTask` / `System.WorkflowDefinition` shapes | [`system-module`](../system-module/SKILL.md) | +| Driving a task end to end and asserting the result | [`test-app`](../test-app/SKILL.md), [`run-local`](../run-local/SKILL.md) | +| Raw admin API, incl. `POST /dev/preview_execute_oql` | [`runtime-admin-api`](../runtime-admin-api/SKILL.md) | Two traps worth knowing before you start: diff --git a/.claude/skills/mendix/xpath-constraints.md b/.claude/skills/mendix/xpath-constraints/SKILL.md similarity index 97% rename from .claude/skills/mendix/xpath-constraints.md rename to .claude/skills/mendix/xpath-constraints/SKILL.md index f7274c29a..c15a48f89 100644 --- a/.claude/skills/mendix/xpath-constraints.md +++ b/.claude/skills/mendix/xpath-constraints/SKILL.md @@ -1,3 +1,8 @@ +--- +name: xpath-constraints +description: "XPath constraint syntax for MDL — retrieve WHERE clauses, page data sources, and row-level entity access, including association paths and functions. Use when writing or debugging any XPath in a project." +--- + # XPath Constraints in MDL This skill provides reference for writing XPath constraint expressions in MDL RETRIEVE statements, page data sources, and security rules. @@ -253,7 +258,7 @@ Note the double single-quotes for escaping inside the string literal. Both forms are accepted by mxcli in the write direction. `DESCRIBE MICROFLOW` always shows the qualified name form for readability, even though BSON stores `'Open'`. -**Do NOT use qualified names in expression context (IF, SET, DECLARE) for comparisons** — those contexts use a different form. See `write-microflows.md` "Enumeration Comparisons" section. +**Do NOT use qualified names in expression context (IF, SET, DECLARE) for comparisons** — those contexts use a different form. See `write-microflows` "Enumeration Comparisons" section. ```mdl -- Preferred (mxcli converts to 'Open' in BSON): diff --git a/CLAUDE.md b/CLAUDE.md index e731d1971..b9cc0ec0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -714,7 +714,7 @@ Both namespaces are discoverable by typing `/mxcli` in Claude Code. Add new cont ### mxcli init `mxcli init` creates a `.claude/` folder with skills, commands, CLAUDE.md, and VS Code MDL extension in a target Mendix project. Source of truth for synced assets: -- Skills: `.claude/skills/mendix/` — `make sync-skills` copies these to the `cmd/mxcli/skills/` embed dir (`//go:embed skills/*.md`), which `mxcli init` writes into the project. **Edit the `mendix/` source, not the embed dir** (it is regenerated). The top-level `.claude/skills/*.md` are contributor/dev skills and are **not** synced. +- Skills: `.claude/skills/mendix//SKILL.md` — directory-shaped, per the [Agent Skills](https://agentskills.io) standard, with `name` and `description` frontmatter. `make sync-skills` mirrors the tree into the `cmd/mxcli/skills/` embed dir (`//go:embed all:skills`), and `mxcli init` writes it into the project **twice**: `.ai-context/skills/` for every tool, and `.claude/skills/` — the only path Claude Code scans — when the project is set up for Claude. **Edit the `mendix/` source, not the embed dir** (it is regenerated, and the sync is `rsync --delete`). The `description` is the routing mechanism; the table in the generated CLAUDE.md is a shortcut, not the index. Upgrading a project retires the flat `.md` files older mxcli versions wrote, but never a skill the user added. The top-level `.claude/skills/*.md` are contributor/dev skills and are **not** synced. - Commands: `.claude/commands/mendix/` (the `mxcli-dev/` folder is **not** synced) - VS Code extension: `vscode-mdl/vscode-mdl-*.vsix` diff --git a/Makefile b/Makefile index 0a52fae45..ff131c97f 100644 --- a/Makefile +++ b/Makefile @@ -44,15 +44,13 @@ define copy-if-changed endef # Sync skills from .claude/skills/mendix to cmd/mxcli/skills for embedding +# Skills are directory-shaped (/SKILL.md, Agent Skills standard), so this +# mirrors a tree rather than copying a flat list. --delete matters: a renamed or +# removed skill must not linger in the embed dir, or the binary keeps shipping it. sync-skills: @mkdir -p cmd/mxcli/skills - @changed=0; for f in .claude/skills/mendix/*.md; do \ - dst="cmd/mxcli/skills/$$(basename $$f)"; \ - if [ ! -f "$$dst" ] || ! cmp -s "$$f" "$$dst"; then \ - cp "$$f" "$$dst"; changed=$$((changed + 1)); \ - fi; \ - done; \ - if [ $$changed -gt 0 ]; then echo "Synced $$changed skill file(s)"; fi + @rsync -a --delete --exclude='.DS_Store' .claude/skills/mendix/ cmd/mxcli/skills/ 2>/dev/null \ + || { rm -rf cmd/mxcli/skills && mkdir -p cmd/mxcli/skills && cp -R .claude/skills/mendix/. cmd/mxcli/skills/; } # Sync skill packs from .claude/skills/packs to cmd/mxcli/skillpacks for embedding. # Recursive, unlike sync-skills: a pack is a directory tree and flattening it diff --git a/README.md b/README.md index c3943d2f6..20bb39203 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ mxcli add-tool cursor **Universal (all tools):** - `AGENTS.md` - Comprehensive guide for AI assistants -- `.ai-context/skills/` - MDL pattern guides (write-microflows.md, create-page.md, etc.) +- `.ai-context/skills/` - MDL pattern guides, one `/SKILL.md` each (write-microflows, create-page, ...) - `.ai-context/examples/` - Example MDL scripts **Tool-Specific:** diff --git a/cmd/mxcli/init.go b/cmd/mxcli/init.go index 9afcdbbce..77d4ff117 100644 --- a/cmd/mxcli/init.go +++ b/cmd/mxcli/init.go @@ -262,33 +262,21 @@ Container Runtime: fmt.Printf(" Created %s (System module excluded from lint)\n", filepath.Base(path)) } - // Write universal skills to .ai-context/skills/ - skillCount := 0 - err = fs.WalkDir(skillsFS, "skills", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - // Read from embedded FS - content, err := skillsFS.ReadFile(path) - if err != nil { - return err - } - // Write to target directory - targetPath := filepath.Join(skillsDir, d.Name()) - if err := os.WriteFile(targetPath, content, 0644); err != nil { - return err - } - skillCount++ - return nil - }) + // Write the skills. One code path with the SessionStart refresh, so a + // fresh init and an upgraded project end up with byte-identical trees — + // including the `.claude/skills/` copy Claude Code actually scans. + skillRes, err := syncAIContextSkills(absDir) if err != nil { fmt.Fprintf(os.Stderr, "Error writing skills: %v\n", err) os.Exit(1) } - fmt.Printf(" Created %d skill files in .ai-context/skills/\n", skillCount) + for _, dest := range skillDests(absDir) { + rel, relErr := filepath.Rel(absDir, dest) + if relErr != nil { + rel = dest + } + fmt.Printf(" Created %d skills in %s/\n", skillRes.Total, filepath.ToSlash(rel)) + } // Write tool-specific configurations for _, toolName := range tools { diff --git a/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index e29ce53d8..256e72b45 100644 --- a/cmd/mxcli/init_claudemd.go +++ b/cmd/mxcli/init_claudemd.go @@ -112,20 +112,24 @@ func generateClaudeMD(projectName, mprFile string) string { // ── IMPORTANT: Before Writing MDL ─────────────────────────────── w("## IMPORTANT: Before Writing MDL Scripts or Working with Data\n\n") w("**Read the relevant skill files FIRST before writing any MDL, seeding data, or doing database/import work:**\n\n") + w("Every skill is a " + bt + "/SKILL.md" + bt + " directory with a " + bt + "description" + bt + " in its frontmatter (the\n") + w("[Agent Skills](https://agentskills.io) standard), so a tool that reads them announces the whole set on\n") + w("its own. The table below is a shortcut to the ones worth reading before you start, **not** the index —\n") + w("list " + bt + ".ai-context/skills/" + bt + " for everything available.\n\n") w("| Skill File | When to Read |\n") w("|------------|-------------|\n") - w("| " + bt + ".ai-context/skills/write-microflows.md" + bt + " | **Before writing any microflow** - syntax, common mistakes, validation checklist |\n") - w("| " + bt + ".ai-context/skills/create-page.md" + bt + " | **Before creating any page** - widget syntax reference |\n") - w("| " + bt + ".ai-context/skills/alter-page.md" + bt + " | **Before modifying pages** - ALTER PAGE/SNIPPET SET, INSERT, DROP, REPLACE |\n") - w("| " + bt + ".ai-context/skills/overview-pages.md" + bt + " | CRUD page patterns (overview + edit) |\n") - w("| " + bt + ".ai-context/skills/master-detail-pages.md" + bt + " | Master-detail page patterns |\n") - w("| " + bt + ".ai-context/skills/generate-domain-model.md" + bt + " | Entity, association, enumeration syntax |\n") - w("| " + bt + ".ai-context/skills/organize-project.md" + bt + " | Folders, MOVE command, project structure |\n") - w("| " + bt + ".ai-context/skills/manage-security.md" + bt + " | Security roles, GRANT/REVOKE, access control |\n") - w("| " + bt + ".ai-context/skills/manage-navigation.md" + bt + " | Navigation profiles, menus, home/login pages |\n") - w("| " + bt + ".ai-context/skills/check-syntax.md" + bt + " | **Pre-flight** validation checklist |\n") - w("| " + bt + ".ai-context/skills/demo-data.md" + bt + " | **READ for any database/import work** - Mendix ID system, demo data |\n") - w("| " + bt + ".ai-context/skills/test-microflows.md" + bt + " | **READ for testing** - test annotations, file formats, Docker setup |\n") + w("| " + bt + ".ai-context/skills/write-microflows/SKILL.md" + bt + " | **Before writing any microflow** - syntax, common mistakes, validation checklist |\n") + w("| " + bt + ".ai-context/skills/create-page/SKILL.md" + bt + " | **Before creating any page** - widget syntax reference |\n") + w("| " + bt + ".ai-context/skills/alter-page/SKILL.md" + bt + " | **Before modifying pages** - ALTER PAGE/SNIPPET SET, INSERT, DROP, REPLACE |\n") + w("| " + bt + ".ai-context/skills/overview-pages/SKILL.md" + bt + " | CRUD page patterns (overview + edit) |\n") + w("| " + bt + ".ai-context/skills/master-detail-pages/SKILL.md" + bt + " | Master-detail page patterns |\n") + w("| " + bt + ".ai-context/skills/generate-domain-model/SKILL.md" + bt + " | Entity, association, enumeration syntax |\n") + w("| " + bt + ".ai-context/skills/organize-project/SKILL.md" + bt + " | Folders, MOVE command, project structure |\n") + w("| " + bt + ".ai-context/skills/manage-security/SKILL.md" + bt + " | Security roles, GRANT/REVOKE, access control |\n") + w("| " + bt + ".ai-context/skills/manage-navigation/SKILL.md" + bt + " | Navigation profiles, menus, home/login pages |\n") + w("| " + bt + ".ai-context/skills/check-syntax/SKILL.md" + bt + " | **Pre-flight** validation checklist |\n") + w("| " + bt + ".ai-context/skills/demo-data/SKILL.md" + bt + " | **READ for any database/import work** - Mendix ID system, demo data |\n") + w("| " + bt + ".ai-context/skills/test-microflows/SKILL.md" + bt + " | **READ for testing** - test annotations, file formats, Docker setup |\n") w("\n") w("**Always validate before presenting to user:**\n\n") w(bt3 + "bash\n") @@ -410,7 +414,9 @@ func generateClaudeMD(projectName, mprFile string) string { // ── Skills Reference ──────────────────────────────────────────── w("## Skills Reference\n\n") - w("Skills are in " + bt + ".ai-context/skills/" + bt + ". Read the relevant skill before starting work.\n\n") + w("Skills are in " + bt + ".ai-context/skills//SKILL.md" + bt + " (and " + bt + ".claude/skills/" + bt + " for Claude\n") + w("Code, which discovers them from there). Each one's frontmatter says what it covers and when to reach\n") + w("for it. Read the relevant skill before starting work.\n\n") w("### Quick Reference\n\n") w("| Skill | Purpose |\n") @@ -633,7 +639,7 @@ func generateClaudeMD(projectName, mprFile string) string { w(bt3 + "\n\n") w("## MDL Reference\n\n") - w("For detailed MDL syntax, see the skill files in " + bt + ".ai-context/skills/" + bt + ".\n") + w("For detailed MDL syntax, see the skill files in " + bt + ".ai-context/skills//SKILL.md" + bt + ".\n") return sb.String() } diff --git a/cmd/mxcli/init_skills_standard_test.go b/cmd/mxcli/init_skills_standard_test.go new file mode 100644 index 000000000..a2af10151 --- /dev/null +++ b/cmd/mxcli/init_skills_standard_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Issue #906: the bundled skills were flat `.md` files with no frontmatter, +// routed by a hand-maintained table in the generated CLAUDE.md that had drifted +// to 12 of 68. Nothing discovered them — `mxcli init` did not even create +// `.claude/skills/`, the one directory Claude Code scans. These tests pin the +// three properties that fix depends on. + +var frontmatter = regexp.MustCompile(`(?s)\A---\n(.*?)\n---\n`) + +// Every shipped skill is a `/SKILL.md` carrying `name` and `description`. +// A skill without a description is no more discoverable than the flat file it +// replaced: the description IS the index now. +func TestEmbeddedSkillsCarryAgentSkillsFrontmatter(t *testing.T) { + names := embeddedSkillNames(t) + if len(names) < 50 { + t.Fatalf("only %d skills embedded; the sync or the embed directive is wrong", len(names)) + } + + for _, n := range names { + body, err := skillsFS.ReadFile("skills/" + n + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", n, err) + continue + } + m := frontmatter.FindSubmatch(body) + if m == nil { + t.Errorf("%s/SKILL.md has no YAML frontmatter", n) + continue + } + fm := string(m[1]) + + var gotName, gotDesc string + for _, line := range strings.Split(fm, "\n") { + if v, ok := strings.CutPrefix(line, "name: "); ok { + gotName = strings.TrimSpace(v) + } + if v, ok := strings.CutPrefix(line, "description: "); ok { + gotDesc = strings.Trim(strings.TrimSpace(v), `"`) + } + } + if gotName != n { + t.Errorf("%s: frontmatter name is %q; it must match the directory or the skill is addressed by two names", n, gotName) + } + switch { + case gotDesc == "": + t.Errorf("%s: no description — nothing can decide when to invoke it", n) + case len(gotDesc) < 40: + t.Errorf("%s: description is %d chars; too short to say when to use it: %q", n, len(gotDesc), gotDesc) + case len(gotDesc) > 600: + t.Errorf("%s: description is %d chars; skill listings are truncated well before that", n, len(gotDesc)) + } + } +} + +// The point of the migration: a Claude project gets `.claude/skills//SKILL.md`, +// which is the only path Claude Code scans. Before this, `mxcli init` created +// `.claude/commands/`, `.claude/lint-rules/` and no skills directory at all. +func TestSyncWritesTheDirectoryClaudeCodeScans(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := syncAIContextSkills(dir); err != nil { + t.Fatal(err) + } + + names := embeddedSkillNames(t) + for _, base := range []string{".ai-context", ".claude"} { + p := filepath.Join(dir, base, "skills", names[0], "SKILL.md") + if _, err := os.Stat(p); err != nil { + t.Errorf("%s not written: %v", p, err) + } + } + + // Control: without `.claude/`, the Claude copy is not created — the project + // was not set up for it, and mxcli should not invent the directory. + bare := t.TempDir() + if _, err := syncAIContextSkills(bare); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(bare, ".claude", "skills")); err == nil { + t.Error(".claude/skills/ was created in a project that has no .claude/") + } +} + +// Upgrading a project written by an older mxcli must retire the flat files it +// used to own — otherwise 67 orphans sit beside the new tree, and an agent +// reading the directory cannot tell which copy is current. A file mxcli never +// wrote is left alone: the docs invite users to add their own skills here. +func TestSyncRetiresLegacyFlatSkillsButKeepsUserFiles(t *testing.T) { + dir := t.TempDir() + skillsDir := filepath.Join(dir, ".ai-context", "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatal(err) + } + + names := embeddedSkillNames(t) + legacy := filepath.Join(skillsDir, names[0]+".md") + if err := os.WriteFile(legacy, []byte("# shipped by an older mxcli\n"), 0o644); err != nil { + t.Fatal(err) + } + mine := filepath.Join(skillsDir, "our-house-conventions.md") + if err := os.WriteFile(mine, []byte("# ours\n"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := syncAIContextSkills(dir) + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(legacy); !os.IsNotExist(err) { + t.Errorf("%s survived the upgrade; it now contradicts %s/SKILL.md", filepath.Base(legacy), names[0]) + } + if !containsString(res.Removed, names[0]+".md") { + t.Errorf("the retirement was not reported: %v", res.Removed) + } + if _, err := os.Stat(mine); err != nil { + t.Error("a user's own skill file was deleted; only names mxcli ships may be retired") + } +} + +func containsString(hay []string, needle string) bool { + for _, h := range hay { + if h == needle { + return true + } + } + return false +} diff --git a/cmd/mxcli/init_skills_sync.go b/cmd/mxcli/init_skills_sync.go index bdf188396..45d7b145d 100644 --- a/cmd/mxcli/init_skills_sync.go +++ b/cmd/mxcli/init_skills_sync.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strings" ) // init_skills_sync.go keeps a project's .ai-context/skills/ in step with the @@ -31,50 +32,150 @@ import ( type skillSyncResult struct { Total int // skills the binary carries Changed []string // names whose on-disk content differed (added or updated) + Removed []string // legacy flat files retired by the directory layout } // Stale reports whether anything on disk disagreed with the binary. -func (r skillSyncResult) Stale() bool { return len(r.Changed) > 0 } +func (r skillSyncResult) Stale() bool { return len(r.Changed) > 0 || len(r.Removed) > 0 } -// syncAIContextSkills rewrites /.ai-context/skills/ from the binary's -// embedded copies, reporting which files differed. Writing only the files that -// changed keeps mtimes meaningful, so "when did this guidance last move" stays +// skillDests returns every directory a project's skills are mirrored into. +// +// `.ai-context/skills/` is the vendor-neutral home and is always written. +// `.claude/skills/` is written too when the project is set up for Claude Code, +// because that is the only path it scans for skills — one level deep, `/ +// SKILL.md`. Before this existed a project carried 68 skills that Claude Code +// could not see at all, and routing depended entirely on a hand-maintained +// table in CLAUDE.md (issue #906). +// +// Presence of `.claude/` is the signal rather than a flag, so the SessionStart +// refresh — which has no memory of which tools were chosen at init — keeps both +// copies current without being told. +func skillDests(projectDir string) []string { + dests := []string{filepath.Join(projectDir, ".ai-context", "skills")} + if st, err := os.Stat(filepath.Join(projectDir, ".claude")); err == nil && st.IsDir() { + dests = append(dests, filepath.Join(projectDir, ".claude", "skills")) + } + return dests +} + +// syncAIContextSkills rewrites a project's skill directories from the binary's +// embedded copies, reporting what differed. Writing only the files that changed +// keeps mtimes meaningful, so "when did this guidance last move" stays // answerable from the filesystem. func syncAIContextSkills(projectDir string) (skillSyncResult, error) { var res skillSyncResult - skillsDir := filepath.Join(projectDir, ".ai-context", "skills") - - entries, err := fs.ReadDir(skillsFS, "skills") - if err != nil { - return res, fmt.Errorf("reading embedded skills: %w", err) + seen := map[string]bool{} + for i, dest := range skillDests(projectDir) { + one, err := syncSkillTree(dest) + if err != nil { + return res, err + } + if i == 0 { + res.Total = one.Total + } + for _, n := range one.Changed { + if !seen["c:"+n] { + seen["c:"+n] = true + res.Changed = append(res.Changed, n) + } + } + for _, n := range one.Removed { + if !seen["r:"+n] { + seen["r:"+n] = true + res.Removed = append(res.Removed, n) + } + } } + sort.Strings(res.Changed) + sort.Strings(res.Removed) + return res, nil +} + +// syncSkillTree mirrors the embedded skills into one destination directory. +func syncSkillTree(skillsDir string) (skillSyncResult, error) { + var res skillSyncResult + if err := os.MkdirAll(skillsDir, 0o755); err != nil { return res, fmt.Errorf("creating %s: %w", skillsDir, err) } - for _, e := range entries { - if e.IsDir() { - continue + live := map[string]bool{} + err := fs.WalkDir(skillsFS, "skills", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, relErr := filepath.Rel("skills", filepath.FromSlash(p)) + if relErr != nil || rel == "." { + return relErr + } + target := filepath.Join(skillsDir, rel) + if d.IsDir() { + live[rel] = true + return os.MkdirAll(target, 0o755) } - want, err := skillsFS.ReadFile("skills/" + e.Name()) + want, err := skillsFS.ReadFile(p) if err != nil { - return res, fmt.Errorf("reading embedded skill %s: %w", e.Name(), err) + return fmt.Errorf("reading embedded skill %s: %w", p, err) + } + live[rel] = true + if filepath.Base(rel) == "SKILL.md" { + res.Total++ } - res.Total++ - - target := filepath.Join(skillsDir, e.Name()) if have, readErr := os.ReadFile(target); readErr == nil && bytes.Equal(have, want) { - continue + return nil + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err } if err := os.WriteFile(target, want, 0o644); err != nil { - return res, fmt.Errorf("writing %s: %w", target, err) + return fmt.Errorf("writing %s: %w", target, err) } - res.Changed = append(res.Changed, e.Name()) + res.Changed = append(res.Changed, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return res, err } + + res.Removed = retireFlatSkills(skillsDir, live) sort.Strings(res.Changed) + sort.Strings(res.Removed) return res, nil } +// retireFlatSkills deletes the pre-#906 flat `.md` files this binary used +// to write, once `/SKILL.md` has replaced them. +// +// The sync has never deleted anything, which was right while the layout was +// stable and is exactly wrong across a layout change: without this, every +// project upgraded from an older mxcli keeps 67 orphaned files that no longer +// match the shipped guidance, sitting beside the ones that do. An agent reading +// the directory cannot tell which is current. +// +// Only names the binary itself used to own are removed, and only when the +// replacement directory is present, so a user's own skill file in the same +// directory — which the docs explicitly invite — is never touched. +func retireFlatSkills(skillsDir string, live map[string]bool) []string { + entries, err := os.ReadDir(skillsDir) + if err != nil { + return nil + } + var removed []string + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") { + continue + } + name := strings.TrimSuffix(e.Name(), ".md") + if !live[filepath.Join(name, "SKILL.md")] { + continue // not a skill this binary ships — leave it alone + } + if err := os.Remove(filepath.Join(skillsDir, e.Name())); err == nil { + removed = append(removed, e.Name()) + } + } + return removed +} + // reportSkillSync prints a one-line summary, and nothing at all when the project // was already current — this runs on every session start, so silence is the // common case and the only acceptable one. @@ -82,6 +183,24 @@ func reportSkillSync(w io.Writer, res skillSyncResult) { if !res.Stale() { return } - fmt.Fprintf(w, "Refreshed %d of %d skill file(s) in .ai-context/skills/ to match this mxcli: %v\n", - len(res.Changed), res.Total, res.Changed) + if len(res.Changed) > 0 { + fmt.Fprintf(w, "Refreshed %d file(s) across %d skills to match this mxcli: %s\n", + len(res.Changed), res.Total, abridge(res.Changed)) + } + if len(res.Removed) > 0 { + fmt.Fprintf(w, "Retired %d file(s) superseded by the /SKILL.md layout: %s\n", + len(res.Removed), abridge(res.Removed)) + } +} + +// abridge renders a file list without turning a first-run or post-upgrade +// refresh into a wall of 68 names. The count above it is the number that +// matters; the names are there to answer "which one moved" on the ordinary +// one-or-two-file refresh. +func abridge(names []string) string { + const max = 6 + if len(names) <= max { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s and %d more", strings.Join(names[:max], ", "), len(names)-max) } diff --git a/cmd/mxcli/init_skills_sync_test.go b/cmd/mxcli/init_skills_sync_test.go index d53ffb1f9..9af19c349 100644 --- a/cmd/mxcli/init_skills_sync_test.go +++ b/cmd/mxcli/init_skills_sync_test.go @@ -11,7 +11,8 @@ import ( "testing" ) -// embeddedSkillNames returns the skills this binary carries. +// embeddedSkillNames returns the skills this binary carries. Skills are +// directory-shaped (`/SKILL.md`), so a name is a directory, not a file. func embeddedSkillNames(t *testing.T) []string { t.Helper() entries, err := fs.ReadDir(skillsFS, "skills") @@ -21,8 +22,13 @@ func embeddedSkillNames(t *testing.T) []string { var names []string for _, e := range entries { if !e.IsDir() { - names = append(names, e.Name()) + continue } + if _, err := skillsFS.ReadFile("skills/" + e.Name() + "/SKILL.md"); err != nil { + t.Errorf("%s has no SKILL.md; it will not be discovered", e.Name()) + continue + } + names = append(names, e.Name()) } if len(names) == 0 { t.Fatal("no embedded skills; the embed directive is broken") @@ -30,6 +36,9 @@ func embeddedSkillNames(t *testing.T) []string { return names } +// skillPath is a skill's file, relative to a skills directory. +func skillPath(name string) string { return filepath.Join(name, "SKILL.md") } + // mxcli-formula1 §16: a project initialised on Monday still served Monday's // skills from Tuesday's binary — the files are written once by `mxcli init` and // nothing re-wrote them on upgrade. Stale guidance, no warning. @@ -43,7 +52,10 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { if err := os.MkdirAll(skillsDir, 0o755); err != nil { t.Fatal(err) } - stale := filepath.Join(skillsDir, names[0]) + if err := os.MkdirAll(filepath.Join(skillsDir, names[0]), 0o755); err != nil { + t.Fatal(err) + } + stale := filepath.Join(skillsDir, skillPath(names[0])) if err := os.WriteFile(stale, []byte("# guidance from an older mxcli\n"), 0o644); err != nil { t.Fatal(err) } @@ -61,11 +73,11 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { // Every embedded skill now matches the binary, not just the one that existed. for _, n := range names { - want, err := skillsFS.ReadFile("skills/" + n) + want, err := skillsFS.ReadFile("skills/" + n + "/SKILL.md") if err != nil { t.Fatal(err) } - got, err := os.ReadFile(filepath.Join(skillsDir, n)) + got, err := os.ReadFile(filepath.Join(skillsDir, skillPath(n))) if err != nil { t.Fatalf("%s missing after sync: %v", n, err) } @@ -76,8 +88,8 @@ func TestSyncAIContextSkills_RefreshesStaleGuidance(t *testing.T) { var out bytes.Buffer reportSkillSync(&out, res) - if !strings.Contains(out.String(), names[0]) { - t.Errorf("the refresh should name what it changed:\n%s", out.String()) + if !strings.Contains(out.String(), "Refreshed") { + t.Errorf("the refresh should report what it changed:\n%s", out.String()) } } @@ -91,12 +103,12 @@ func TestSyncAIContextSkills_CurrentProjectIsSilentAndUntouched(t *testing.T) { if err != nil { t.Fatalf("first sync failed: %v", err) } - if len(first.Changed) != first.Total { - t.Fatalf("a fresh project should write every skill: %d of %d", len(first.Changed), first.Total) + if len(first.Changed) < first.Total { + t.Fatalf("a fresh project should write every skill: %d files for %d skills", len(first.Changed), first.Total) } skillsDir := filepath.Join(dir, ".ai-context", "skills") - probe := filepath.Join(skillsDir, first.Changed[0]) + probe := filepath.Join(skillsDir, filepath.FromSlash(first.Changed[0])) before, err := os.Stat(probe) if err != nil { t.Fatal(err) diff --git a/cmd/mxcli/skills_content.go b/cmd/mxcli/skills_content.go index 7751e51dd..f1ac90347 100644 --- a/cmd/mxcli/skills_content.go +++ b/cmd/mxcli/skills_content.go @@ -16,9 +16,14 @@ import ( "embed" ) -// Embed all skill files from the synced directory +// Embed all skill files from the synced directory. // -//go:embed skills/*.md +// Skills are directory-shaped — `/SKILL.md` per the Agent Skills standard +// — so this embeds a tree, and `all:` is required for the same reason it is on +// skillpacks below: a plain go:embed of a directory skips `_`- and `.`-prefixed +// files, and a skill may carry references or assets beside its SKILL.md. +// +//go:embed all:skills var skillsFS embed.FS // Embed skill packs from the synced directory — skills that carry assets, not diff --git a/cmd/mxcli/tool_templates.go b/cmd/mxcli/tool_templates.go index 871335075..71f2ef4ce 100644 --- a/cmd/mxcli/tool_templates.go +++ b/cmd/mxcli/tool_templates.go @@ -186,7 +186,7 @@ See AGENTS.md for complete documentation and .ai-context/skills/ for patterns. ## Before Writing MDL -1. Read relevant skill file: .ai-context/skills/write-microflows.md or create-page.md +1. Read relevant skill file: .ai-context/skills/write-microflows/SKILL.md or create-page/SKILL.md 2. Validate: ./mxcli check script.mdl -p %s --references 3. Execute: ./mxcli exec script.mdl -p %s `, projectName, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile, mprFile) @@ -237,12 +237,12 @@ func generateContinueConfig(projectName, mprPath string) string { { "name": "mdl-syntax", "description": "Show MDL syntax reference", - "prompt": "Read and summarize: .ai-context/skills/write-microflows.md" + "prompt": "Read and summarize: .ai-context/skills/write-microflows/SKILL.md" }, { "name": "page-syntax", "description": "Show page creation syntax", - "prompt": "Read and summarize: .ai-context/skills/create-page.md" + "prompt": "Read and summarize: .ai-context/skills/create-page/SKILL.md" } ] } @@ -257,7 +257,7 @@ func generateAiderConfig(projectName, mprPath string) string { # Files to read for context read-files: - AGENTS.md - - .ai-context/skills/*.md + - .ai-context/skills/*/SKILL.md # Project description description: | @@ -442,7 +442,7 @@ system_prompt_id = "mendix-mdl" # Skills from .vibe/skills/ are auto-discovered # Additional context files -# skill_paths = [".ai-context/skills"] +# skill_paths = [".ai-context/skills"] # /SKILL.md per skill # Tool permissions for MDL workflow [tools.bash] @@ -564,7 +564,7 @@ func generateOpenCodeConfig(projectName, mprPath string) string { "instructions": [ "AGENTS.md", ".opencode/skills/**/SKILL.md", - ".ai-context/skills/*.md" + ".ai-context/skills/*/SKILL.md" ] } ` diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index a7bdc48e0..2d6a85736 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -37,7 +37,7 @@ This is an empty repo. Provision it as a Mendix app developed with mxcli. ./mxcli init --sync-skills ``` -3. Read `.ai-context/skills/bootstrap-app.md` and follow it end to end. It begins by +3. Read `.ai-context/skills/bootstrap-app/SKILL.md` and follow it end to end. It begins by interviewing me about the app, so ask me those questions and wait for my answers before running anything else. diff --git a/docs-site/src/tutorial/claude-code.md b/docs-site/src/tutorial/claude-code.md index 7d8f9cf2f..be9e26491 100644 --- a/docs-site/src/tutorial/claude-code.md +++ b/docs-site/src/tutorial/claude-code.md @@ -44,7 +44,7 @@ The `CLAUDE.md` file gives Claude Code project-level context. It describes what ### Skills -The `.claude/skills/` directory (and `.ai-context/skills/` for the universal copy) contains markdown files that teach Claude specific MDL patterns. For example, `write-microflows.md` explains microflow syntax, common mistakes, and a validation checklist. Claude reads the relevant skill before generating MDL, which dramatically improves output quality. +The `.claude/skills/` directory (and `.ai-context/skills/` for the universal copy) holds one directory per skill, each with a `SKILL.md`. For example, `write-microflows/SKILL.md` explains microflow syntax, common mistakes, and a validation checklist. Claude Code discovers them from `.claude/skills/` automatically and reads the relevant one before generating MDL, which dramatically improves output quality. ### Commands diff --git a/docs-site/src/tutorial/opencode.md b/docs-site/src/tutorial/opencode.md index 20d941179..2f15c2fda 100644 --- a/docs-site/src/tutorial/opencode.md +++ b/docs-site/src/tutorial/opencode.md @@ -54,7 +54,7 @@ The `opencode.json` file is OpenCode's primary configuration. It points to `AGEN "instructions": [ "AGENTS.md", ".opencode/skills/**/SKILL.md", - ".ai-context/skills/*.md" + ".ai-context/skills/*/SKILL.md" ] } ``` diff --git a/docs-site/src/tutorial/skills.md b/docs-site/src/tutorial/skills.md index bc67db5d0..379c79f59 100644 --- a/docs-site/src/tutorial/skills.md +++ b/docs-site/src/tutorial/skills.md @@ -1,17 +1,53 @@ # Skills and CLAUDE.md -Skills are markdown files that teach AI assistants how to write correct MDL. Each skill covers a specific topic -- creating pages, writing microflows, managing security -- and contains syntax references, examples, and validation checklists. When an AI assistant needs to generate MDL, it reads the relevant skill first, which dramatically improves output quality. +Skills teach AI assistants how to write correct MDL. Each skill covers a specific topic -- creating pages, writing microflows, managing security -- and contains syntax references, examples, and validation checklists. When an AI assistant needs to generate MDL, it reads the relevant skill first, which dramatically improves output quality. + +## The format + +Skills follow the [Agent Skills](https://agentskills.io) standard: each one is a +directory holding a `SKILL.md`, whose YAML frontmatter says what it covers and +when to use it. + +``` +.ai-context/skills/ +└── write-microflows/ + └── SKILL.md +``` + +```markdown +--- +name: write-microflows +description: Microflow syntax reference in MDL -- every activity type, control flow, + expressions, and the mistakes that fail `mxcli check`. Use before writing any + CREATE MICROFLOW, and when debugging a microflow syntax error. +--- + +# Mendix Microflow Skill +... +``` + +The `description` is what makes a skill findable. A tool that reads skills loads +every description up front -- a line or two each -- and pulls in the full body +only when one is actually needed. So the assistant knows the whole set exists +without any of them costing context until used, and without a hand-maintained +index that can fall out of step. ## Where skills live -Skills are installed in two locations: +`mxcli init` installs the same set in two places: | Location | Used by | |----------|---------| -| `.claude/skills/` | Claude Code (tool-specific copy) | -| `.ai-context/skills/` | All tools (universal copy) | +| `.ai-context/skills//SKILL.md` | All tools -- the vendor-neutral copy, referenced by the generated OpenCode, Cursor, Continue, Windsurf and Aider configs | +| `.claude/skills//SKILL.md` | Claude Code, which discovers skills from this path automatically | -Both directories contain the same files. The `.claude/skills/` copy exists because Claude Code has a built-in mechanism for reading files from its `.claude/` directory. The `.ai-context/skills/` copy is the universal location that any AI tool can access. +The `.claude/skills/` copy exists because that is the only directory Claude Code +scans, one level deep. Both copies are refreshed from the mxcli binary on every +session by `mxcli init --sync-skills`, so they cannot drift apart. + +If you are upgrading a project created by an older mxcli, the flat `.md` +files it used to write are retired automatically on the next refresh. Skill files +you added yourself are left alone. ## Available skills @@ -19,21 +55,21 @@ Both directories contain the same files. The `.claude/skills/` copy exists becau | Skill File | Topic | |------------|-------| -| `generate-domain-model.md` | Entity, attribute, and association syntax | -| `write-microflows.md` | Microflow syntax, activities, common mistakes | -| `create-page.md` | Page and widget syntax reference | -| `alter-page.md` | ALTER PAGE/SNIPPET for modifying existing pages | -| `overview-pages.md` | CRUD page patterns (overview + edit) | -| `master-detail-pages.md` | Master-detail page patterns | -| `manage-security.md` | Module roles, user roles, access control, GRANT/REVOKE | -| `manage-navigation.md` | Navigation profiles, home pages, menus | -| `demo-data.md` | Mendix ID system, association storage, demo data insertion | -| `xpath-constraints.md` | XPath syntax in WHERE clauses, nested predicates | -| `database-connections.md` | External database connections from microflows | -| `check-syntax.md` | Pre-flight validation checklist | -| `organize-project.md` | Folders, MOVE command, project structure conventions | -| `test-microflows.md` | Test annotations, file formats, Docker setup | -| `patterns-data-processing.md` | Delta merge, batch processing, list operations | +| `generate-domain-model/SKILL.md` | Entity, attribute, and association syntax | +| `write-microflows/SKILL.md` | Microflow syntax, activities, common mistakes | +| `create-page/SKILL.md` | Page and widget syntax reference | +| `alter-page/SKILL.md` | ALTER PAGE/SNIPPET for modifying existing pages | +| `overview-pages/SKILL.md` | CRUD page patterns (overview + edit) | +| `master-detail-pages/SKILL.md` | Master-detail page patterns | +| `manage-security/SKILL.md` | Module roles, user roles, access control, GRANT/REVOKE | +| `manage-navigation/SKILL.md` | Navigation profiles, home pages, menus | +| `demo-data/SKILL.md` | Mendix ID system, association storage, demo data insertion | +| `xpath-constraints/SKILL.md` | XPath syntax in WHERE clauses, nested predicates | +| `database-connections/SKILL.md` | External database connections from microflows | +| `check-syntax/SKILL.md` | Pre-flight validation checklist | +| `organize-project/SKILL.md` | Folders, MOVE command, project structure conventions | +| `test-microflows/SKILL.md` | Test annotations, file formats, Docker setup | +| `patterns-data-processing/SKILL.md` | Delta merge, batch processing, list operations | ## What a skill file contains @@ -106,20 +142,33 @@ Think of `CLAUDE.md` as the "system prompt" for Claude Code in the context of yo ## Adding custom skills -You can create your own skill files to teach the AI about your project's patterns and conventions. Add markdown files to `.ai-context/skills/` (or `.claude/skills/` for Claude Code): +You can create your own skills to teach the AI about your project's patterns and conventions. Add a directory with a `SKILL.md` to `.ai-context/skills/` (and `.claude/skills/` if you use Claude Code): ``` .ai-context/skills/ -├── write-microflows.md # Built-in (installed by mxcli init) -├── create-page.md # Built-in -├── our-naming-conventions.md # Custom: your team's naming rules -├── order-processing-pattern.md # Custom: how orders work in your app -└── api-integration-guide.md # Custom: how to call external APIs +├── write-microflows/SKILL.md # Built-in (installed by mxcli init) +├── create-page/SKILL.md # Built-in +├── our-naming-conventions/SKILL.md # Custom: your team's naming rules +├── order-processing-pattern/SKILL.md # Custom: how orders work in your app +└── api-integration-guide/SKILL.md # Custom: how to call external APIs ``` -A custom skill file is just a markdown document. Write it the same way you would explain something to a new team member: +`mxcli init --sync-skills` only rewrites the skills mxcli itself ships, so your +own directories survive every upgrade. + +A custom skill is a markdown document with two lines of frontmatter. Write the +body the way you would explain something to a new team member, and write the +`description` so an assistant can tell from it alone whether this is the skill +for the job: ```markdown +--- +name: order-processing-pattern +description: How orders are processed in this application -- validation rules, + status enumeration, logging node and confirmation email. Use when writing or + changing any microflow that touches an Order. +--- + # Order Processing Pattern When creating microflows that process orders in our application, follow these rules: From 724a2e42f48a20b2c0049d553b605928eb701031 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 21:01:03 +0000 Subject: [PATCH 5/7] feat(widgets): make the generated widget docs a real, complete skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-project widget docs were named skills, written into a skills directory, and were not skills: no frontmatter, no SKILL.md, an `_index.md` whose leading underscore hides it from a glob. In the `.claude` case they landed at exactly the depth Claude Code scans — `.claude/skills/widgets/` — so they looked discoverable and were silently skipped. Nothing routed an agent to them either: one line at the end of a 1,395-line skill said how to GENERATE them. The content was the weaker half of the two channels that exist. Measured on a 42-widget project, before: 134 enumeration rows naming 0 permitted values 23 object rows exposing 0 child properties 75 descriptions cut mid-sentence at 77 chars ("...to denote number posit...") `mxcli widget describe` reads the SAME mpk.PropertyDef and printed all of it — enum sets, nested children, categories. Only this renderer dropped them, on exactly the properties an author cannot guess. After: 171 of 171 enumeration rows name their values, 213 nested rows appear, 0 truncations. Pipes in cells are escaped, which they never were — one in a description silently split the row into extra columns. Four changes: 1. The renderer emits enum values, nested object children (recursively, system properties still excluded), whole descriptions, and the property group. 2. `_index.md` becomes `SKILL.md` with generated frontmatter whose description NAMES the project's widgets — "Badge, Area chart, Data grid 2 …". A hand-written skill cannot do that, so the listing now answers "does this project have a chart widget" for no context cost. The index links each per-widget file, which is what makes them supporting files rather than inert ones. The old `_index.md` is removed on regeneration. 3. `.def.json` files are refreshed BEFORE rendering. They carry the MDL child-slot routing, and without them the example collapses to a bare one-liner — so `mxcli widget docs` on a fresh project emitted a data grid documented as `PLUGGABLEWIDGET '…' widget1` with nothing about columns, while `refresh catalog` happened to produce the full version. The output depended on which command ran last. Widgets with no extractable def now fall back to the embedded registry definition, which covers the built-ins that are deliberately never extracted (combobox, gallery, image, …) and were being reported as failures: 9 such widgets, now 4, and those four are leaf widgets with no child blocks to route. 4. The body routes onward instead of pretending to be the whole reference — `mxcli widget describe ` for data read live from the .mpk, and for the dynamic visibility rules these files do not render at all. `create-page` now points at the skill rather than at the generator command. Docs are written to `.ai-context/skills/widgets/` AND `.claude/skills/widgets/`, matching what `mxcli init` does for the bundled skills; it used to be either/or, so a Claude project with `.ai-context/` present got its widget docs in only one of the two trees. Corpus grows 61 KB → 149 KB, which is the point of supporting files: none of it loads until a reader opens one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/mendix/create-page/SKILL.md | 10 +- .claude/skills/mendix/custom-widgets/SKILL.md | 2 +- cmd/mxcli/cmd_widget.go | 17 +- mdl/executor/widget_defs.go | 298 +++++++++++++++--- mdl/executor/widget_docs_skill_test.go | 190 +++++++++++ 5 files changed, 464 insertions(+), 53 deletions(-) create mode 100644 mdl/executor/widget_docs_skill_test.go diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 26fce2ba6..2f6895d8d 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -1387,7 +1387,15 @@ pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( ) ``` -Run `mxcli widget docs -p app.mpr` to generate complete property documentation for all pluggable widgets in the project. Output is saved to `.ai-context/skills/widgets/`. +The project's own widgets are documented as a skill: read +`.ai-context/skills/widgets/SKILL.md` (also at `.claude/skills/widgets/SKILL.md`) +for the index, then the per-widget file for the one you are placing — it carries +the full property table with enumeration values, nested object properties, child +slots and object lists. + +`mxcli widget docs -p app.mpr` regenerates it (so does `refresh catalog`), and +`mxcli widget describe -p app.mpr` reads the same data live from the +`.mpk` when a widget has been upgraded since. ## See Also diff --git a/.claude/skills/mendix/custom-widgets/SKILL.md b/.claude/skills/mendix/custom-widgets/SKILL.md index deb5eb8e5..77af8b0cf 100644 --- a/.claude/skills/mendix/custom-widgets/SKILL.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -1,6 +1,6 @@ --- name: custom-widgets -description: "MDL syntax for pluggable widgets already installed in a project — Gallery, DataGrid2, ComboBox and the rest, including their datasource and column forms. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting." +description: "MDL syntax for pluggable widgets already installed in a project — Gallery, DataGrid2, ComboBox and the rest, including their datasource and column forms. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting. For the widgets THIS project actually has, read the generated `widgets` skill." --- --- diff --git a/cmd/mxcli/cmd_widget.go b/cmd/mxcli/cmd_widget.go index 0cd9c8601..57639a1ee 100644 --- a/cmd/mxcli/cmd_widget.go +++ b/cmd/mxcli/cmd_widget.go @@ -49,7 +49,7 @@ var widgetInitCmd = &cobra.Command{ Use: "init", Short: "Extract definitions for all project widgets", Long: `Scan the project's widgets/ directory, extract .def.json for each .mpk, -and generate skill documentation in .claude/skills/widgets/. +and generate a widget skill in .ai-context/skills/widgets/ and .claude/skills/widgets/. This enables CREATE PAGE to use any project widget via the pluggable engine. @@ -68,8 +68,12 @@ Requires --project (-p) to locate the project's widgets/ directory.`, var widgetDocsCmd = &cobra.Command{ Use: "docs", Short: "Generate widget skill documentation", - Long: `Generate per-widget markdown documentation in .claude/skills/widgets/ from .mpk definitions.`, - RunE: runWidgetDocs, + Long: "Generate a widget skill from the project's .mpk definitions.\n\n" + + "Writes SKILL.md — the index, with Agent Skills frontmatter naming the widgets\n" + + "found — plus one file per widget carrying its full property table, enumeration\n" + + "values, nested object properties, child slots and object lists. Written to\n" + + ".ai-context/skills/widgets/ and .claude/skills/widgets/, whichever exist.", + RunE: runWidgetDocs, } func init() { @@ -213,12 +217,9 @@ func generateWidgetDocs(projectPath string) error { return err } if generated > 0 { - projectDir := filepath.Dir(projectPath) - docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets") - if _, statErr := os.Stat(filepath.Join(projectDir, ".ai-context")); statErr == nil { - docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets") + for _, dir := range executor.WidgetDocsDirs(filepath.Dir(projectPath)) { + fmt.Printf("Generated %d widget docs in %s\n", generated, dir) } - fmt.Printf("Generated %d widget docs in %s\n", generated, docsDir) } return nil } diff --git a/mdl/executor/widget_defs.go b/mdl/executor/widget_defs.go index 709476e91..1e8a23467 100644 --- a/mdl/executor/widget_defs.go +++ b/mdl/executor/widget_defs.go @@ -652,10 +652,6 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { projectDir := filepath.Dir(projectPath) widgetsDir := filepath.Join(projectDir, "widgets") defsDir := filepath.Join(projectDir, ".mxcli", "widgets") - docsDir := filepath.Join(projectDir, ".claude", "skills", "widgets") - if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil { - docsDir = filepath.Join(projectDir, ".ai-context", "skills", "widgets") - } matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk")) if err != nil { @@ -665,12 +661,42 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { return 0, nil } - if err := os.MkdirAll(docsDir, 0755); err != nil { - return 0, fmt.Errorf("failed to create docs directory: %w", err) + // Make sure the .def.json files exist BEFORE rendering. They carry the MDL + // keyword routing — child slots and object lists — and without them the + // generated example collapses to a bare one-liner with no `{ … }` block at + // all. `mxcli widget docs` on a fresh project used to emit exactly that, for + // every widget, with no indication anything was missing: a data grid + // documented as `PLUGGABLEWIDGET '…' widget1` and nothing about columns. + // Only `refresh catalog` happened to generate defs first, so the output + // depended on which command the user had run last. + // + // Best-effort: a project whose defs cannot be extracted still gets docs, just + // the thinner ones — that is strictly better than no docs, and the caller is + // told how many widgets ended up without routing. + if _, defErr := RefreshWidgetDefinitions(projectPath, false, nil); defErr != nil { + log.Printf("warning: widget definitions could not be refreshed, MDL examples will omit child slots: %v", defErr) + } + + docsDirs := WidgetDocsDirs(projectDir) + for _, d := range docsDirs { + if err := os.MkdirAll(d, 0755); err != nil { + return 0, fmt.Errorf("failed to create docs directory: %w", err) + } + } + + // The embedded definitions cover the built-in widgets that are never + // extracted to .def.json. Best-effort: a registry that cannot be built just + // means those widgets fall back to the thinner rendering. + registry, regErr := NewWidgetRegistry() + if regErr != nil { + log.Printf("warning: widget registry unavailable, built-in widgets will omit child slots: %v", regErr) + registry = nil } var generated int var indexEntries []string + var widgetNames []string + var withoutRouting []string for _, mpkPath := range matches { // A bundled .mpk (e.g. Charts.mpk) contains many widgetFiles; ParseMPK @@ -684,11 +710,16 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { for _, mpkDef := range mpkDefs { mdlName := DeriveMDLName(mpkDef.ID) filename := strings.ToLower(mdlName) + ".md" - outPath := filepath.Join(docsDir, filename) - // Load the matching .def.json (may not exist for built-in widgets like - // COMBOBOX / GALLERY — those have hand-crafted definitions in - // sdk/widgets/definitions/ that we don't extract per-project). + // Load the matching .def.json, falling back to the embedded definition. + // + // Built-in widgets like COMBOBOX and GALLERY have hand-crafted + // definitions in sdk/widgets/definitions/ and are deliberately not + // extracted per-project, so their .def.json never exists. Reading only + // from disk therefore documented nine widgets — combobox, gallery, the + // four data-grid filters, dropdownsort, image, barcodescanner — with an + // MDL example missing every child block, as if extraction had failed. + // The registry has had their routing all along. var def *WidgetDefinition defPath := filepath.Join(defsDir, strings.ToLower(mdlName)+".def.json") if data, err := os.ReadFile(defPath); err == nil { @@ -697,41 +728,41 @@ func RegenerateWidgetDocs(projectPath string) (int, error) { def = nil } } + if def == nil && registry != nil { + if embedded, ok := registry.Get(mdlName); ok { + def = embedded + } + } doc := widgetDocMarkdown(mpkDef, def, mdlName) - if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil { - log.Printf("warning: failed to write %s: %v", filename, err) + if writeErr := writeToAll(docsDirs, filename, doc); writeErr != nil { + log.Printf("warning: failed to write %s: %v", filename, writeErr) continue } + if def == nil { + withoutRouting = append(withoutRouting, mdlName) + } kind := "CUSTOMWIDGET" if mpkDef.IsPluggable { kind = "PLUGGABLEWIDGET" } - indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |", - kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) + indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | [%s](%s) | `%s` | %s | %d |", + kind, mdlName, filename, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) + widgetNames = append(widgetNames, mpkDef.Name) generated++ } } - var indexBuf strings.Builder - indexBuf.WriteString("# Available Widgets\n\n") - indexBuf.WriteString("Auto-generated. See individual files for property details, child slots, and object lists.\n\n") - indexBuf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n") - indexBuf.WriteString("|--------|------|-----------|--------------|-------|\n") - for _, entry := range indexEntries { - indexBuf.WriteString(entry) - indexBuf.WriteString("\n") + skill := widgetSkillMarkdown(indexEntries, widgetNames, withoutRouting) + if err := writeToAll(docsDirs, "SKILL.md", skill); err != nil { + return generated, fmt.Errorf("failed to write skill: %w", err) } - indexBuf.WriteString("\n**Usage in MDL:**\n```sql\n") - indexBuf.WriteString("-- React pluggable widgets\n") - indexBuf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n") - indexBuf.WriteString("-- Legacy custom widgets\n") - indexBuf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n") - indexBuf.WriteString("```\n") - - if err := os.WriteFile(filepath.Join(docsDir, "_index.md"), []byte(indexBuf.String()), 0644); err != nil { - return generated, fmt.Errorf("failed to write index: %w", err) + // `_index.md` was the pre-#906 name, and it is not a skill: a leading + // underscore hides it from a plain glob, and nothing discovered it. Retire it + // so an upgraded project does not keep a second, staler index beside SKILL.md. + for _, d := range docsDirs { + _ = os.Remove(filepath.Join(d, "_index.md")) } return generated, nil @@ -773,22 +804,13 @@ func widgetDocMarkdown(mpkDef *mpk.WidgetDefinition, def *WidgetDefinition, mdlN if len(mpkDef.Properties) > 0 { buf.WriteString("## Properties\n\n") - buf.WriteString("| Property | Type | Required | Default | Description |\n") - buf.WriteString("|----------|------|----------|---------|-------------|\n") + buf.WriteString("| Property | Type | Required | Default | Values / notes | Group | Description |\n") + buf.WriteString("|----------|------|----------|---------|----------------|-------|-------------|\n") for _, prop := range mpkDef.Properties { if prop.IsSystem { continue } - req := "" - if prop.Required { - req = "Yes" - } - desc := prop.Description - if len(desc) > 80 { - desc = desc[:77] + "..." - } - buf.WriteString(fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n", - prop.Key, prop.Type, req, prop.DefaultValue, desc)) + writePropertyRow(&buf, prop, 0) } buf.WriteString("\n") } @@ -825,5 +847,195 @@ func widgetDocMarkdown(mpkDef *mpk.WidgetDefinition, def *WidgetDefinition, mdlN } } + buf.WriteString(fmt.Sprintf("---\n\nRegenerated by `mxcli widget docs` and by `refresh catalog`. "+ + "For the same data live from the `.mpk` — including anything added by a widget upgrade since this "+ + "file was written — run `mxcli widget describe %s -p `.\n", strings.ToLower(mdlName))) + + return buf.String() +} + +// writePropertyRow renders one property, then its children indented beneath it. +// +// The three columns beyond name/type exist because their absence was the whole +// problem: an `enumeration` row showed its default but never its alternatives, +// and an `object` row showed nothing at all about the properties that go inside +// it — so `columns` in a data grid documented itself as "object, required" and +// left the reader unable to write a single column. Measured across a 42-widget +// project: 134 enumeration rows named 0 permitted values, 23 object rows exposed +// 0 children. `mxcli widget describe` had all of it; only this renderer dropped it. +func writePropertyRow(buf *strings.Builder, prop mpk.PropertyDef, depth int) { + req := "" + if prop.Required { + req = "Yes" + } + + // Enumerations name their options, and an object property announces the + // children rendered beneath it. Their absence was the whole problem: an + // `enumeration` row showed its default but never its alternatives, and an + // `object` row said nothing about what goes inside — so `columns` in a data + // grid documented itself as "object, required" and left the reader unable to + // write a single column. Measured across a 42-widget project: 134 enumeration + // rows named 0 permitted values, 23 object rows exposed 0 children. + // `mxcli widget describe` reads the SAME PropertyDef and printed all of it; + // only this renderer dropped it. + var notes []string + if len(prop.EnumValues) > 0 { + keys := make([]string, 0, len(prop.EnumValues)) + for _, v := range prop.EnumValues { + keys = append(keys, "`"+v+"`") + } + notes = append(notes, strings.Join(keys, " \\| ")) + } + if prop.IsList { + notes = append(notes, "list") + } + if prop.OnChange != "" { + notes = append(notes, "on change → `"+prop.OnChange+"`") + } + if len(prop.Children) > 0 { + notes = append(notes, fmt.Sprintf("%d sub-properties below", len(prop.Children))) + } + + name := "`" + prop.Key + "`" + if depth > 0 { + name = strings.Repeat(" ", depth*4) + "↳ `" + prop.Key + "`" + } + + desc := prop.Description + if desc == "" { + desc = prop.Caption + } + + buf.WriteString(fmt.Sprintf("| %s | %s | %s | %s | %s | %s | %s |\n", + name, prop.Type, req, cellText(prop.DefaultValue), + strings.Join(notes, "; "), cellText(prop.Category), cellText(desc))) + + for _, child := range prop.Children { + if child.IsSystem { + continue + } + writePropertyRow(buf, child, depth+1) + } +} + +// cellText makes a value safe for a markdown table cell. +// +// Descriptions used to be cut at 77 characters plus an ellipsis, which reliably +// removed the operative half of the sentence ("Must include '%d' to denote number +// posit…"). They are kept whole now; newlines are folded because a table cell +// cannot hold one, and pipes are escaped because an unescaped one silently splits +// the row into extra columns. +func cellText(s string) string { + s = strings.Join(strings.Fields(s), " ") + return strings.ReplaceAll(s, "|", "\\|") +} + +// WidgetDocsDirs returns every skills directory the widget docs belong in. +// +// It used to be either/or — `.ai-context/skills/widgets/` when `.ai-context` +// existed, `.claude/skills/widgets/` otherwise — which meant a Claude project +// with `.ai-context/` present got its bundled skills in `.claude/skills/` and its +// widget docs only in the other tree. `mxcli init` writes skills to both, so +// these follow the same rule. +func WidgetDocsDirs(projectDir string) []string { + var dirs []string + if _, err := os.Stat(filepath.Join(projectDir, ".ai-context")); err == nil { + dirs = append(dirs, filepath.Join(projectDir, ".ai-context", "skills", "widgets")) + } + if _, err := os.Stat(filepath.Join(projectDir, ".claude")); err == nil { + dirs = append(dirs, filepath.Join(projectDir, ".claude", "skills", "widgets")) + } + if len(dirs) == 0 { + // Neither tree exists yet: keep the historical default so a project that + // has never seen `mxcli init` still gets something. + dirs = append(dirs, filepath.Join(projectDir, ".claude", "skills", "widgets")) + } + return dirs +} + +// writeToAll writes one generated file into every destination directory. +func writeToAll(dirs []string, name, content string) error { + for _, d := range dirs { + if err := os.WriteFile(filepath.Join(d, name), []byte(content), 0644); err != nil { + return err + } + } + return nil +} + +// widgetSkillMarkdown renders the SKILL.md that fronts the per-widget files. +// +// This is the navigation half of the Agent Skills progressive-disclosure shape: +// the description is always loaded, this body loads when the skill is invoked, +// and the per-widget files load only when the body sends a reader to one. The +// description names the project's actual widgets, which a hand-written skill +// cannot do — so "does this project have a chart widget?" is answerable from the +// skill listing alone, at no context cost. +func widgetSkillMarkdown(indexEntries, widgetNames, withoutRouting []string) string { + var buf strings.Builder + + buf.WriteString("---\n") + buf.WriteString("name: widgets\n") + buf.WriteString("description: " + widgetSkillDescription(widgetNames) + "\n") + buf.WriteString("---\n\n") + + buf.WriteString("# Widgets in this project\n\n") + buf.WriteString("Generated from the `.mpk` files in `widgets/` by `mxcli widget docs` and by\n") + buf.WriteString("`refresh catalog`. One file per widget holds its full property table, child\n") + buf.WriteString("slots and object lists — **read the file for the widget you are placing**, not\n") + buf.WriteString("this page.\n\n") + + buf.WriteString("| Prefix | Name | Widget ID | Display Name | Props |\n") + buf.WriteString("|--------|------|-----------|--------------|-------|\n") + for _, entry := range indexEntries { + buf.WriteString(entry) + buf.WriteString("\n") + } + + buf.WriteString("\n## Usage in MDL\n\n```sql\n") + buf.WriteString("-- React pluggable widgets\n") + buf.WriteString("PLUGGABLEWIDGET 'com.mendix.widget.custom.badge.Badge' badge1\n\n") + buf.WriteString("-- Legacy custom widgets\n") + buf.WriteString("CUSTOMWIDGET 'com.company.OldWidget' legacy1\n") + buf.WriteString("```\n\n") + + buf.WriteString("## When these files are not enough\n\n") + buf.WriteString("They are a snapshot: a widget upgraded since the last `refresh catalog` is\n") + buf.WriteString("described here as it was, not as it is. For the same data read live from the\n") + buf.WriteString("`.mpk`, plus the dynamic visibility rules that are not rendered here at all:\n\n") + buf.WriteString("```bash\n") + buf.WriteString("mxcli widget describe -p # e.g. datagrid, combobox\n") + buf.WriteString("mxcli widget list -p # every widget, one line each\n") + buf.WriteString("```\n\n") + buf.WriteString("Prefer `describe` when a property does not behave as this file says it should.\n") + + if len(withoutRouting) > 0 { + buf.WriteString("\n## Widgets without child-block routing\n\n") + buf.WriteString("mxcli has no MDL child-slot mapping for these, so their example is the widget\n") + buf.WriteString("line alone. For a leaf widget (a filter, an input) that is simply correct. If\n") + buf.WriteString("one of them needs a `{ … }` block, the example will not tell you — check\n") + buf.WriteString("`mxcli widget describe ` for properties of type `widgets` or `object`:\n\n") + for _, n := range withoutRouting { + buf.WriteString("- `" + strings.ToLower(n) + "`\n") + } + } + return buf.String() } + +// widgetSkillDescription builds the frontmatter description, naming as many of +// the project's widgets as fit. The names are the point: they let a reader rule +// the skill in or out without opening it. +func widgetSkillDescription(names []string) string { + const maxNames = 12 + shown := names + suffix := "" + if len(names) > maxNames { + shown = names[:maxNames] + suffix = fmt.Sprintf(" and %d more", len(names)-maxNames) + } + list := strings.Join(shown, ", ") + suffix + return fmt.Sprintf("%q", "The pluggable and custom widgets installed in THIS project and how to write them in MDL — "+ + list+". Use before placing any PLUGGABLEWIDGET or CUSTOMWIDGET on a page, or when a widget's "+ + "property names, enumeration values or child blocks need checking.") +} diff --git a/mdl/executor/widget_docs_skill_test.go b/mdl/executor/widget_docs_skill_test.go new file mode 100644 index 000000000..607eb01c9 --- /dev/null +++ b/mdl/executor/widget_docs_skill_test.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +// The generated widget docs are read by agents and by nothing else — no Go code +// loads them back — so their content IS the contract. These tests pin the three +// facts that were missing, each measured on a 42-widget project before the fix: +// 134 enumeration rows named 0 permitted values, 23 object rows exposed 0 +// children, and 75 descriptions were cut mid-sentence at 77 characters. + +func enumProp() mpk.PropertyDef { + return mpk.PropertyDef{ + Key: "itemSelectionMethod", Type: "enumeration", Required: true, + DefaultValue: "checkbox", EnumValues: []string{"checkbox", "rowClick"}, + Category: "General::General", Description: "Selection method", + } +} + +func objectProp() mpk.PropertyDef { + return mpk.PropertyDef{ + Key: "columns", Type: "object", Required: true, IsList: true, + Category: "General::Columns", + Children: []mpk.PropertyDef{ + {Key: "showContentAs", Type: "enumeration", Required: true, + DefaultValue: "attribute", EnumValues: []string{"attribute", "dynamicText"}}, + {Key: "header", Type: "textTemplate"}, + {Key: "hidden", Type: "boolean", IsSystem: true}, + }, + } +} + +// An enumeration that shows its default but not its alternatives cannot be +// filled in from the doc. `mxcli widget describe` reads the same PropertyDef and +// always printed `{checkbox|rowClick}`; only this renderer dropped it. +func TestWidgetDocRendersEnumerationValues(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{enumProp()}, + }, nil, "Y") + + for _, want := range []string{"`checkbox`", "`rowClick`"} { + if !strings.Contains(doc, want) { + t.Errorf("enumeration value %s missing; the reader cannot know what is allowed:\n%s", want, doc) + } + } +} + +// An `object` row that says only "object, required" leaves the reader unable to +// write a single entry of it. The children are on the same struct. +func TestWidgetDocRendersNestedObjectChildren(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Grid", Name: "Grid", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{objectProp()}, + }, nil, "Grid") + + for _, want := range []string{"showContentAs", "header", "sub-properties below"} { + if !strings.Contains(doc, want) { + t.Errorf("%q missing from the rendered doc:\n%s", want, doc) + } + } + if !strings.Contains(doc, "↳") { + t.Error("children are not visibly nested under their parent") + } + // System properties are not authorable in MDL and were already excluded at + // the top level; the recursion must not reintroduce them. + if strings.Contains(doc, "hidden") { + t.Error("a system sub-property was rendered") + } +} + +// Descriptions were cut at 77 characters plus an ellipsis, which reliably +// removed the operative half ("Must include '%d' to denote number posit..."). +func TestWidgetDocKeepsWholeDescriptions(t *testing.T) { + long := "Must include '%d' to denote number position, and the row count is substituted at runtime before the string reaches assistive technology." + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{{Key: "selectedCountTemplatePlural", Type: "textTemplate", Description: long}}, + }, nil, "Y") + + if strings.Contains(doc, "...") { + t.Error("a description was truncated") + } + if !strings.Contains(doc, "assistive technology") { + t.Error("the end of the description was lost") + } +} + +// A pipe in a description silently splits the markdown row into extra columns, +// so the table stops parsing from that row on. +func TestWidgetDocEscapesPipesInCells(t *testing.T) { + doc := widgetDocMarkdown(&mpk.WidgetDefinition{ + ID: "com.x.Y", Name: "Y", Version: "1.0", IsPluggable: true, + Properties: []mpk.PropertyDef{{Key: "mode", Type: "string", Description: "one | two"}}, + }, nil, "Y") + + for _, line := range strings.Split(doc, "\n") { + if strings.Contains(line, "`mode`") { + if strings.Count(line, "|")-strings.Count(line, `\|`) != 8 { + t.Errorf("row has the wrong column count, an unescaped pipe leaked: %q", line) + } + } + } +} + +// The skill front page must be a real skill: frontmatter, and a description that +// names the project's own widgets — the thing a hand-written skill cannot do, +// and what lets a reader rule it in or out without opening it. +func TestWidgetSkillMarkdownIsADiscoverableSkill(t *testing.T) { + skill := widgetSkillMarkdown( + []string{"| `PLUGGABLEWIDGET` | [BADGE](badge.md) | `com.x.Badge` | Badge | 3 |"}, + []string{"Badge", "Data grid 2"}, nil) + + if !strings.HasPrefix(skill, "---\nname: widgets\ndescription: ") { + t.Fatalf("no Agent Skills frontmatter:\n%s", skill[:min(200, len(skill))]) + } + for _, want := range []string{"Badge", "Data grid 2"} { + if !strings.Contains(skill, want) { + t.Errorf("the description does not name %q, so the listing cannot answer what this project has", want) + } + } + // It routes onward rather than trying to be the whole reference. + if !strings.Contains(skill, "mxcli widget describe") { + t.Error("no pointer to the always-fresh command") + } + if !strings.Contains(skill, "[BADGE](badge.md)") { + t.Error("the index does not link the per-widget files, so nothing tells a reader they exist") + } +} + +// End to end: `widget docs` must write a usable skill into every skills tree, +// retire the old `_index.md`, and never leave the pre-#906 name behind. +func TestRegenerateWidgetDocsWritesSkillToBothTrees(t *testing.T) { + dir := t.TempDir() + projectPath := filepath.Join(dir, "App.mpr") + if err := os.MkdirAll(filepath.Join(dir, "widgets"), 0o755); err != nil { + t.Fatal(err) + } + for _, d := range []string{".ai-context", ".claude"} { + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { + t.Fatal(err) + } + } + const fixture = "../../testdata/expr-checker/widgets/Charts.mpk" + src, err := os.ReadFile(fixture) + if err != nil { + t.Skipf("Charts.mpk fixture not available: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "widgets", "Charts.mpk"), src, 0o644); err != nil { + t.Fatal(err) + } + + // A project upgraded from an older mxcli still carries the underscore index. + for _, d := range []string{".ai-context", ".claude"} { + wd := filepath.Join(dir, d, "skills", "widgets") + if err := os.MkdirAll(wd, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wd, "_index.md"), []byte("# old\n"), 0o644); err != nil { + t.Fatal(err) + } + } + + if _, err := RegenerateWidgetDocs(projectPath); err != nil { + t.Fatalf("RegenerateWidgetDocs: %v", err) + } + + for _, d := range []string{".ai-context", ".claude"} { + wd := filepath.Join(dir, d, "skills", "widgets") + body, err := os.ReadFile(filepath.Join(wd, "SKILL.md")) + if err != nil { + t.Errorf("%s/SKILL.md not written: %v", d, err) + continue + } + if !strings.HasPrefix(string(body), "---\nname: widgets\n") { + t.Errorf("%s/SKILL.md has no frontmatter", d) + } + if _, err := os.Stat(filepath.Join(wd, "_index.md")); !os.IsNotExist(err) { + t.Errorf("%s/_index.md survived; a stale second index sits beside SKILL.md", d) + } + } +} From b0cab659469a0c3312941169f9970e968608ae5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:12:49 +0000 Subject: [PATCH 6/7] refactor(skills): split the largest skills into SKILL.md plus reference files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agent Skills standard loads a skill in three tiers — every description always, the SKILL.md body when the skill is used, a supporting file only when the body sends a reader to one — and advises keeping SKILL.md under 500 lines. Nine of the bundled skills were between 740 and 1906, and they are the ones agents load most often, so their whole length was paid on every use. write-microflows 1906 -> 587 (4 reference files) create-page 1395 -> 444 (2) odata-data-sharing 1369 -> 304 (2) java-actions 1209 -> 541 (2) generate-domain-model 1145 -> 234 (2) atlas-design 811 -> 438 (2) write-oql-queries 800 -> 565 (1) test-microflows 778 -> 499 (1) system-module 740 -> 432 (1) The body keeps the decisions, the shape and the gotchas — what is needed every time. The catalogues and long build-outs move out: the widget catalogue (862 lines of create-page), the four OData walkthroughs, the domain-model syntax tables, the Atlas inventories, the anti-pattern and unsupported-syntax lists. Sections are moved WHOLE and never rewritten, so nothing can be lost in the edit. Verified mechanically: every `## ` heading that existed before still exists, in the body or in a reference file, for all nine. Two properties this depends on now have tests: * Every supporting file is linked from its SKILL.md. An unlinked one is dead weight — Claude follows a link it can see and nothing else — and that cannot be checked by reading a single file. * No SKILL.md exceeds 700 lines. Deliberately looser than the documented 500: a body that is all essential beats one that hit a number by dropping something. It is what pulled in the last three skills, which were over the bound with no supporting files at all. The bound test also removed the need for an allowlist, which is why nine were split rather than the six largest. One real break the split caused, now fixed: a link from moved content to a sibling skill (`../generate-domain-model/SKILL.md`) needs another `../` from inside `reference/`. The five remaining broken relative links in the skill set pre-date this change and point at repo paths that do not resolve from a shipped project either way. `make check-skill-mdl` still finds and validates the same 206 MDL blocks — the checker recurses, so moving a block into `reference/` keeps it covered — and `mxcli init` delivers all 17 reference files into both skill trees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/mendix/atlas-design/SKILL.md | 421 +---- .../atlas-design/reference/building-blocks.md | 265 ++++ .../reference/dark-mode-and-charts.md | 125 ++ .claude/skills/mendix/create-page/SKILL.md | 983 +----------- .../mendix/create-page/reference/examples.md | 108 ++ .../mendix/create-page/reference/widgets.md | 865 +++++++++++ .../mendix/generate-domain-model/SKILL.md | 937 +----------- .../reference/patterns.md | 311 ++++ .../generate-domain-model/reference/syntax.md | 616 ++++++++ .claude/skills/mendix/java-actions/SKILL.md | 692 +-------- .../java-actions/reference/best-practices.md | 308 ++++ .../java-actions/reference/writing-java.md | 376 +++++ .../skills/mendix/odata-data-sharing/SKILL.md | 1091 +------------ .../reference/errors-and-auth.md | 259 ++++ .../reference/walkthroughs.md | 819 ++++++++++ .claude/skills/mendix/system-module/SKILL.md | 322 +--- .../reference/workflow-and-queues.md | 317 ++++ .../skills/mendix/test-microflows/SKILL.md | 293 +--- .../test-microflows/reference/annotations.md | 289 ++++ .../skills/mendix/write-microflows/SKILL.md | 1357 +---------------- .../reference/control-flow.md | 378 +++++ .../reference/data-operations.md | 238 +++ .../write-microflows/reference/integration.md | 212 +++ .../write-microflows/reference/pitfalls.md | 509 +++++++ .../skills/mendix/write-oql-queries/SKILL.md | 247 +-- .../write-oql-queries/reference/patterns.md | 243 +++ cmd/mxcli/init_skills_references_test.go | 75 + 27 files changed, 6430 insertions(+), 6226 deletions(-) create mode 100644 .claude/skills/mendix/atlas-design/reference/building-blocks.md create mode 100644 .claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md create mode 100644 .claude/skills/mendix/create-page/reference/examples.md create mode 100644 .claude/skills/mendix/create-page/reference/widgets.md create mode 100644 .claude/skills/mendix/generate-domain-model/reference/patterns.md create mode 100644 .claude/skills/mendix/generate-domain-model/reference/syntax.md create mode 100644 .claude/skills/mendix/java-actions/reference/best-practices.md create mode 100644 .claude/skills/mendix/java-actions/reference/writing-java.md create mode 100644 .claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md create mode 100644 .claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md create mode 100644 .claude/skills/mendix/system-module/reference/workflow-and-queues.md create mode 100644 .claude/skills/mendix/test-microflows/reference/annotations.md create mode 100644 .claude/skills/mendix/write-microflows/reference/control-flow.md create mode 100644 .claude/skills/mendix/write-microflows/reference/data-operations.md create mode 100644 .claude/skills/mendix/write-microflows/reference/integration.md create mode 100644 .claude/skills/mendix/write-microflows/reference/pitfalls.md create mode 100644 .claude/skills/mendix/write-oql-queries/reference/patterns.md create mode 100644 cmd/mxcli/init_skills_references_test.go diff --git a/.claude/skills/mendix/atlas-design/SKILL.md b/.claude/skills/mendix/atlas-design/SKILL.md index b0f418b39..1686dfa35 100644 --- a/.claude/skills/mendix/atlas-design/SKILL.md +++ b/.claude/skills/mendix/atlas-design/SKILL.md @@ -5,6 +5,20 @@ description: "Make a Mendix app look designed rather than default-Atlas: layout, # Atlas Design — Make a Mendix App Look Designed, Not Bland +## Reference files + +`SKILL.md` covers the thesis, the layer architecture, the workflow and the +gotchas. The inventories are next door: + +- [`reference/building-blocks.md`](reference/building-blocks.md) — what Atlas + ships out of the box (layouts, page templates, building blocks, widgets) and the + appearance vocabulary: the classes and design properties available on each. + **Look here before writing custom SCSS** — most of what people hand-roll already + exists as a class. +- [`reference/dark-mode-and-charts.md`](reference/dark-mode-and-charts.md) — a + dataviz-grade theme for the Mendix chart widgets, and the optional per-widget + overrides that make dark mode look deliberate rather than inverted. + ## When to Use This Skill Use this skill when: @@ -26,17 +40,16 @@ built on the Atlas building blocks every Mendix project already ships. 1. [The thesis: be Atlas-first](#the-thesis-be-atlas-first) 2. [The 4-layer architecture](#the-4-layer-architecture) 3. [The workflow: discover → inspect → use](#the-workflow-discover--inspect--use) -4. [Atlas building blocks — the out-of-the-box inventory](#atlas-building-blocks--the-out-of-the-box-inventory) -5. [Atlas appearance vocabulary — classes & design properties](#atlas-appearance-vocabulary--classes--design-properties) -6. [Brand re-tune (Layer 1) — where most of the win is](#brand-re-tune-layer-1--where-most-of-the-win-is) -7. [Layer 1 in practice — start from the shipped theme](#layer-1-in-practice--start-from-the-shipped-theme) -8. [Charts — a dataviz-grade theme for the Mendix chart widgets](#charts--a-dataviz-grade-theme-for-the-mendix-chart-widgets) -9. [Dark mode — Mendix 11 makes this cheap](#dark-mode--mendix-11-makes-this-cheap) -10. [Optional dark-mode Atlas-widget overrides](#optional-dark-mode-atlas-widget-overrides) -11. [Verify at runtime — this is mandatory](#verify-at-runtime--this-is-mandatory) -12. [Gotchas catalog](#gotchas-catalog) -13. [Validation checklist](#validation-checklist) -14. [Related skills](#related-skills) +4. [Brand re-tune (Layer 1) — where most of the win is](#brand-re-tune-layer-1--where-most-of-the-win-is) +5. [Layer 1 in practice — start from the shipped theme](#layer-1-in-practice--start-from-the-shipped-theme) +6. [Dark mode — Mendix 11 makes this cheap](#dark-mode--mendix-11-makes-this-cheap) +7. [Verify at runtime — this is mandatory](#verify-at-runtime--this-is-mandatory) +8. [Gotchas catalog](#gotchas-catalog) +9. [Validation checklist](#validation-checklist) +10. [Related skills](#related-skills) + +Inventories and the two long theming sections live beside this file — see +[Reference files](#reference-files) above. --- @@ -168,269 +181,6 @@ one-line `use building block`. --- -## Atlas building blocks — the out-of-the-box inventory - -Every Mendix project ships **`Atlas_Web_Content`**, a library of **39 building -blocks**: pre-composed widget shapes that Mendix itself uses. They are the canonical -reference for "what a well-made X looks like in Atlas." - -### The inventory (real names, grouped by category) - -| Category | Blocks | -|---|---| -| **Cards** | `Card`, `Card_Action`, `Card_ActionWithImage`, `Card_Background`, `Card_WithImage` | -| **Headers** | `Heroheader`, `Heroheader_Background`, `Heroheader_WithAction`, `Pageheader`, `Pageheader_WithBack`, `Pageheader_WithControls`, `Pageheader_WithSearch`, `PageheaderImage`, `PageheaderImage_WithBack`, `PageheaderImage_WithControls` | -| **Forms** | `Form_Horizontal`, `Form_Horizontal_WithTitle`, `Form_Horizontal_WithAction`, `Form_Vertical`, `Form_Vertical_WithTitle`, `Form_Vertical_WithAction` | -| **Lists** | `List_Cards`, `List_WithImage`, `ListItem_SingleLine`, `ListItem_DoubleLine`, `ListItem_WithImage` | -| **Master Detail** | `Master_Detail` | -| **Timeline** | `Timeline`, `Timeline_WithImage` | -| **Wizards** | `Wizard_Arrow`, `Wizard_Arrow_Step`, `Wizard_Circle`, `Wizard_Circle_Step` | -| **Notifications** | `Alert`, `Alert_WithAction`, `AlertIcon`, `AlertIcon_WithAction` | -| **Breadcrumbs** | `Breadcrumb`, `Breadcrumb_Underline` | - -All are `Platform: Web`, all live in module `Atlas_Web_Content`, referenced as -`Atlas_Web_Content.`. - -> Your project may ship more blocks from installed modules (e.g. a feedback widget). -> Always `show building blocks` on the actual project rather than trusting this list — -> it is the standard Atlas baseline, not an exhaustive per-project inventory. - -### Capability reality: discover, inspect, and instantiate - -| Capability | State | -|---|---| -| **Discover** — `SHOW BUILDING BLOCKS`, `CATALOG.building_blocks` | ✅ shipped | -| **Inspect** — `DESCRIBE BUILDING BLOCK Mod.Name` (full widget tree) | ✅ shipped | -| **Instantiate** — `use building block Mod.Name [as prefix_]` onto a page | ✅ v1 (deep-copy; configure afterwards with `alter page`; legacy engine today) | -| **Author** — `CREATE BUILDING BLOCK` | ❌ not yet (proposed) | - -The one-line `use building block` (above) is the normal path — deep-copy the block, -then configure the copy. **Mirroring** — reproducing a block's widget tree by hand — -is the fallback for hand-tuning or the modelsdk engine; the how-to is below. - -### How to mirror a block - -1. **Inspect it.** `describe building block Atlas_Web_Content.`. -2. **Read both channels.** Atlas blocks style with `Class:` strings *and* typed - `DesignProperties:` — copy both. -3. **Reproduce the tree** on your page, binding real data where the block has - placeholder text (`'Card title'` → your attribute/content). -4. **DRY it** — if the shape repeats, put it in a `define fragment` and `use` it. - -### Worked example — `Card` - -`describe building block Atlas_Web_Content.Card` yields the tree shown above. Mirror -it onto a page, binding real content: - -```mdl -create page MyModule.CardDemo -( - title: 'Card demo', - layout: Atlas_Core.Atlas_Default -) -{ - container myCard (designproperties: ['Card style': on]) { - dynamictext cardTitle - ( - content: 'Customers', - rendermode: H4, - class: 'card-title', - designproperties: ['Spacing': ['margin-bottom': 'L']] - ) - } -}; -``` - -Reusable version — put the card **shell** in a fragment with a `slot`, then fill -the slot with each card's own content. This is the key idiom: one card wrapper, -arbitrary bodies, no copy-paste of the wrapper markup. - -```mdl -define fragment SectionCard as { - container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { - container cardBody (class: 'card-body') { - slot content -- each page's widgets land here - } - } -}; - -create page MyModule.Dashboard -( - title: 'Dashboard', - layout: Atlas_Core.Atlas_Default -) -{ - container page1 (class: 'flex-column') { - use fragment SectionCard { - dynamictext custTitle (content: 'Customers', rendermode: H4, class: 'card-title') - dynamictext custBody (content: 'Recent customer activity') - } - use fragment SectionCard { - dynamictext ordTitle (content: 'Orders', rendermode: H4, class: 'card-title') - datagrid ordGrid (datasource: database MyModule.Order) { } - } - } -}; -``` - -The `slot` marker is resolved at expansion — `describe page` shows the fully -wrapped tree (`card1 > cardBody > custTitle, custBody`), and `mx check` is clean. -The slot name is optional (defaults to `content`); a fragment supports one slot. -Use `as prefix_` when the wrapper's *own* widget names would collide across uses -(the payload keeps the names you give it). For a fixed, content-invariant group -(a footer, a button pair) a plain slotless fragment is still the right tool. - -**Binding data and behaviour (experimental).** A slot varies *what widgets* go -inside; typed **parameters** vary *which entity* and *which microflow*. Declare a -`datasource` and/or `action` parameter and the card becomes a real component: - -```mdl -define fragment EntityCard($data: datasource, $onOpen: action) as { - container card1 (designproperties: ['Card style': on]) { - listview lv (datasource: $data) { - slot content - actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) - } - } -}; -use fragment EntityCard ($data: database Sales.Order, $onOpen: microflow Sales.Open) { - dynamictext cardTitle (content: 'Orders', rendermode: H4, class: 'card-title') -} -``` - -Atlas **building blocks** can't declare params, but `use building block` takes -rebind overrides that rewrite the block's outermost datasource / first button: - -```mdl -use building block Atlas_Web_Content.List_Cards - (datasource: database Sales.Order, action: microflow Sales.Open) as orders_; -``` - -For a binding the override rule can't reach, copy the block in (`as prefix_`) and -`alter page … set datasource/action on prefix_widget`. - -### Worked example — `Pageheader` - -`describe building block Atlas_Web_Content.Pageheader`: - -``` -{ - container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { - dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') - dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', - DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) - } -} -``` - -Mirror: - -```mdl -create page MyModule.CustomersHeaderDemo -( - title: 'Customers', - layout: Atlas_Core.Atlas_Default -) -{ - container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { - dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') - dynamictext headerSubtitle - ( - content: 'All active accounts', - rendermode: Paragraph, - class: 'pageheader-subtitle', - designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] - ) - } -}; -``` - -### Block → screen map (which block to reach for) - -| You want | Mirror this block | -|---|---| -| A titled surface panel | `Card` / `Card_Action` (with a trailing action) / `Card_WithImage` | -| A page title + subtitle band | `Pageheader` (+ `_WithBack` / `_WithControls` / `_WithSearch`) | -| A big splash header | `Heroheader` (+ `_Background` / `_WithAction`) | -| A vertical / horizontal form | `Form_Vertical*` / `Form_Horizontal*` | -| A card/list feed | `List_Cards`, `List_WithImage`, `ListItem_*` | -| A master list + detail pane | `Master_Detail` | -| An activity/history feed | `Timeline` / `Timeline_WithImage` | -| A multi-step flow | `Wizard_Arrow` / `Wizard_Circle` (+ their `_Step`) | -| An inline notice | `Alert`, `AlertIcon` (+ `_WithAction`) | -| A path/breadcrumb trail | `Breadcrumb` / `Breadcrumb_Underline` | - ---- - -## Atlas appearance vocabulary — classes & design properties - -Atlas exposes its whole appearance system through the styling channels mxcli can -write today: raw `class:` strings and typed `designproperties:`. **Reach for these -before writing custom CSS.** - -### The cheat-sheet - -Apply via `class:` on any widget (space-join several: `class:'card flex-column'`). - -| Concern | Atlas classes | -|---|---| -| **Cards** | `card`, `cards` (+ Card-style variants) — real CSS, `.card` is ~19 rules | -| **Backgrounds** | `background-{default,main,primary,secondary,success,warning,danger}` | -| **Buttons** | `btn-{primary,secondary,success,warning,danger}`, `btn-{lg,sm,bordered,block,icon-right,icon-top}` | -| **Flex / align** | `flex-{row,column,nowrap,items-grow,items-shrink}`, `align-x-{left,center,right,between,around,evenly}`, `align-y-*` | -| **Spacing utils** | `spacing-{outer,inner}-{top,right,bottom,left}` (+ `-medium` / `-large` / `-none` sizes) | -| **Borders / overflow** | `div-border-toggle-{all,top,…,none}`, `div-overflow-{auto,hidden,visible}` (+ border radius/color/style/width) | -| **Elevation** | `Shadow` toggle | -| **Data grids** | `datagrid-{bordered,hover,striped,lined,lg,sm}` | -| **Group boxes** | `groupbox-{primary,danger,secondary,callout}` | - -Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a -specific widget offers, run `show design properties` / `describe styling` -(`theme-styling`). - -### When to reach for each - -- **`card` / `Card style`** — any titled surface panel. This is the workhorse; a - dashboard is mostly cards on a `background-main` page. -- **`background-primary` / `background-success` / …** — coloured section/hero/status - surfaces. These resolve to your **retuned brand tokens** (Layer 1), so a hero band - set to `background-primary` turns *your* brand colour automatically. -- **`btn-*`** — prefer `buttonstyle: primary` on `actionbutton` for the semantic - style; add `btn-lg` / `btn-bordered` / `btn-block` as classes for size and shape. -- **`flex-row` / `flex-column` + `align-x-*` / `align-y-*`** — layout inside a - container without a `layoutgrid`. `flex-row` + `align-x-between` is the standard - "title on the left, action on the right" header row. -- **`spacing-inner-*` / `spacing-outer-*`** — padding/margin without inline `style:`. -- **`datagrid-*`** — reach for these on data grids before overriding grid CSS. -- **`groupbox-*`** — callouts / grouped sections with a semantic tint. - -### Typed design properties — the alternative channel - -Atlas building blocks use **both** channels side by side. The typed channel is what -Studio Pro's Appearance tab reads, so mirror it when you want the block to round-trip -cleanly into Studio Pro. Common mappings: - -| Class-style | Typed design-property equivalent | -|---|---| -| `class:'card'` | `designproperties: ['Card style': on]` | -| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | -| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | -| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | -| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | -| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | -| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | - -**Both channels render identically at runtime** — raw `class:` is sufficient for the -visual result today. The typed channel matters for Studio Pro round-trip and is the -more idiomatic form to mirror from a `describe building block`. Notes: -- Design-property **keys are case-sensitive** — match the `describe` output exactly. -- Compound properties (Spacing, Border) take a **nested list**: - `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. -- **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` - or wrap in a styled `container`. (`theme-styling`.) - ---- - ## Brand re-tune (Layer 1) — where most of the win is Retune the palette in `theme/web/custom-variables.scss` — the file @@ -511,62 +261,6 @@ app-level rules. --- -## Charts — a dataviz-grade theme for the Mendix chart widgets - -Out of the box the chart widgets (Column / Bar / Area / Pie / Line) render **raw -Plotly defaults**: one flat colour, a floating mode-bar, wide margins, heavy -gridlines, a white paper background. That is the single biggest "not a real product" -tell. Three Plotly hooks — barely used by generated apps — turn them into designed -charts. All three are **plain JSON strings** (no Mendix expression quoting). - -| Property | Plotly layer | Use it for | -|---|---|---| -| `customLayout` | `layout` | transparent `paper_bgcolor` + `plot_bgcolor`, system font, `#8a94a6` ticks, tight `margin`, faint `gridcolor`, `zeroline:false` / `showline:false`, dark `hoverlabel` | -| `customConfigurations` | `config` | `{"displayModeBar":false,"responsive":true}` — removes the floating toolbar | -| `customSeriesOptions` (per series; chart-level on Pie) | trace | brand colour, `marker.cornerradius` (rounded bars), `line.shape:"spline"` + translucent `fillcolor` (area), Pie colour array + white inside labels | - -**The key trick — transparent background = theme-agnostic charts.** Set -`paper_bgcolor` and `plot_bgcolor` to `rgba(0,0,0,0)`; the plot inherits whatever -panel it sits on, so **one config is correct in both light and dark** with zero -per-theme CSS. Pair it with a neutral tick colour (`#8a94a6`) that reads on either -background. Always kill the white paper **and** the mode-bar — the two ugliest -defaults. - -Ready-made `customLayout` (transparent, themed): -```json -{ - "paper_bgcolor": "rgba(0,0,0,0)", - "plot_bgcolor": "rgba(0,0,0,0)", - "font": { "family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "color": "#8a94a6" }, - "margin": { "t": 8, "r": 8, "b": 32, "l": 40 }, - "xaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, - "yaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, - "hoverlabel": { "bgcolor": "#1a2129", "font": { "color": "#ffffff" } } -} -``` - -`customConfigurations` (kill the mode-bar): `{ "displayModeBar": false, "responsive": true }` - -`customSeriesOptions` per type: -```jsonc -// Column / Bar — brand colour + rounded corners -{ "marker": { "color": "#2b5170", "cornerradius": 6 } } -// Area — spline curve + translucent fill -{ "line": { "shape": "spline", "color": "#2b5170" }, "fill": "tozeroy", "fillcolor": "rgba(43,81,112,0.15)" } -// Pie (chart-level) — colour array + white inside labels -{ "marker": { "colors": ["#2b5170", "#4a7a5c", "#c9a227", "#a13a2c"] }, "insidetextfont": { "color": "#ffffff" } } -``` - -Swap the hex values for your brand palette (the same values you set in the Layer-1 -scaffold). The generic `dataviz` skill is the HTML/React analogue of this — same -"kill the defaults, one theme-agnostic config, brand the series" philosophy. - -**Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types -(incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see -`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets`. - ---- - ## Dark mode — Mendix 11 makes this cheap Older guidance here said to commit to a single theme, because a @@ -608,73 +302,6 @@ Atlas corners a token flip misses. --- -## Optional dark-mode Atlas-widget overrides - -Paste into `main.scss` (Layer 2), after the `@import`s. Replace the token -placeholders with your dark palette. Popovers/modals render at ``, so the -popover + modal block must **not** be scoped to your app class — keep it global. - -```scss -// --- Dark palette tokens (TODO: set these) ---------------------------------- -$dk-surface: #1a2129; // panel / row background -$dk-surface-2: #232c37; // header / chip background -$dk-ink: #e6ebf1; // primary text -$dk-ink-mut: #9aa6b4; // muted text -$dk-border: #2f3a47; // hairline - -// Wrap in the media query for a dual-theme app; DELETE the @media line (and its -// closing brace) for a committed dark-only app to make these unconditional. -@media (prefers-color-scheme: dark) { - - // Form controls: text input / textarea / combobox field - .form-control, - .mx-textarea textarea, - .form-control input { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - - // Datagrid: rows, headers, filter chips - .mx-datagrid table, .mx-datagrid tr, .mx-datagrid th, .mx-datagrid td { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - .filter-selector-button { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; - } - - // Datagrid dropdown filter: kill the hardcoded white scroll-fade gradient - .widget-dropdown-filter-menu { - background-image: none; background-color: $dk-surface; - } - .widget-dropdown-filter-menu * { color: $dk-ink; } - - // Accordion / Fieldset - .mx-groupbox, .mx-groupbox-header, fieldset, legend { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; - } - - // TreeNode: expanded child rows carry a WHITE card bg — let the panel show through - .mx-treenode, .mx-treenode .mx-treenode-content { - background: transparent; color: $dk-ink; - } -} - -// Popovers / modals render at — theme these GLOBALLY (unscoped). -// Combobox / tooltip / dropdown-filter popovers and edit popups (.mx-window / -// .modal-content) live outside your app class, so a scoped selector misses them. -.mx-window-content, .modal-content, .mx-window-header, .mx-tooltip, .mx-combobox-menu { - background: $dk-surface; color: $dk-ink; border-color: $dk-border; -} -.mx-window-content .form-control, .modal-content .form-control { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; -} -.mx-window .btn-default, .modal-content .btn-default { - background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; -} -// Charts: DON'T style them here — use the transparent customLayout (above). -``` - ---- - ## Verify at runtime — this is mandatory **Runtime verification is not optional.** `mx check` (and `mxcli check --references`) diff --git a/.claude/skills/mendix/atlas-design/reference/building-blocks.md b/.claude/skills/mendix/atlas-design/reference/building-blocks.md new file mode 100644 index 000000000..f90311557 --- /dev/null +++ b/.claude/skills/mendix/atlas-design/reference/building-blocks.md @@ -0,0 +1,265 @@ +# Atlas building blocks and appearance vocabulary + +Supporting reference for [atlas-design](../SKILL.md). + +## Atlas building blocks — the out-of-the-box inventory + +Every Mendix project ships **`Atlas_Web_Content`**, a library of **39 building +blocks**: pre-composed widget shapes that Mendix itself uses. They are the canonical +reference for "what a well-made X looks like in Atlas." + +### The inventory (real names, grouped by category) + +| Category | Blocks | +|---|---| +| **Cards** | `Card`, `Card_Action`, `Card_ActionWithImage`, `Card_Background`, `Card_WithImage` | +| **Headers** | `Heroheader`, `Heroheader_Background`, `Heroheader_WithAction`, `Pageheader`, `Pageheader_WithBack`, `Pageheader_WithControls`, `Pageheader_WithSearch`, `PageheaderImage`, `PageheaderImage_WithBack`, `PageheaderImage_WithControls` | +| **Forms** | `Form_Horizontal`, `Form_Horizontal_WithTitle`, `Form_Horizontal_WithAction`, `Form_Vertical`, `Form_Vertical_WithTitle`, `Form_Vertical_WithAction` | +| **Lists** | `List_Cards`, `List_WithImage`, `ListItem_SingleLine`, `ListItem_DoubleLine`, `ListItem_WithImage` | +| **Master Detail** | `Master_Detail` | +| **Timeline** | `Timeline`, `Timeline_WithImage` | +| **Wizards** | `Wizard_Arrow`, `Wizard_Arrow_Step`, `Wizard_Circle`, `Wizard_Circle_Step` | +| **Notifications** | `Alert`, `Alert_WithAction`, `AlertIcon`, `AlertIcon_WithAction` | +| **Breadcrumbs** | `Breadcrumb`, `Breadcrumb_Underline` | + +All are `Platform: Web`, all live in module `Atlas_Web_Content`, referenced as +`Atlas_Web_Content.`. + +> Your project may ship more blocks from installed modules (e.g. a feedback widget). +> Always `show building blocks` on the actual project rather than trusting this list — +> it is the standard Atlas baseline, not an exhaustive per-project inventory. + +### Capability reality: discover, inspect, and instantiate + +| Capability | State | +|---|---| +| **Discover** — `SHOW BUILDING BLOCKS`, `CATALOG.building_blocks` | ✅ shipped | +| **Inspect** — `DESCRIBE BUILDING BLOCK Mod.Name` (full widget tree) | ✅ shipped | +| **Instantiate** — `use building block Mod.Name [as prefix_]` onto a page | ✅ v1 (deep-copy; configure afterwards with `alter page`; legacy engine today) | +| **Author** — `CREATE BUILDING BLOCK` | ❌ not yet (proposed) | + +The one-line `use building block` (above) is the normal path — deep-copy the block, +then configure the copy. **Mirroring** — reproducing a block's widget tree by hand — +is the fallback for hand-tuning or the modelsdk engine; the how-to is below. + +### How to mirror a block + +1. **Inspect it.** `describe building block Atlas_Web_Content.`. +2. **Read both channels.** Atlas blocks style with `Class:` strings *and* typed + `DesignProperties:` — copy both. +3. **Reproduce the tree** on your page, binding real data where the block has + placeholder text (`'Card title'` → your attribute/content). +4. **DRY it** — if the shape repeats, put it in a `define fragment` and `use` it. + +### Worked example — `Card` + +`describe building block Atlas_Web_Content.Card` yields the tree shown above. Mirror +it onto a page, binding real content: + +```mdl +create page MyModule.CardDemo +( + title: 'Card demo', + layout: Atlas_Core.Atlas_Default +) +{ + container myCard (designproperties: ['Card style': on]) { + dynamictext cardTitle + ( + content: 'Customers', + rendermode: H4, + class: 'card-title', + designproperties: ['Spacing': ['margin-bottom': 'L']] + ) + } +}; +``` + +Reusable version — put the card **shell** in a fragment with a `slot`, then fill +the slot with each card's own content. This is the key idiom: one card wrapper, +arbitrary bodies, no copy-paste of the wrapper markup. + +```mdl +define fragment SectionCard as { + container card1 (designproperties: ['Card style': on, 'Spacing': ['margin-bottom': 'Large']]) { + container cardBody (class: 'card-body') { + slot content -- each page's widgets land here + } + } +}; + +create page MyModule.Dashboard +( + title: 'Dashboard', + layout: Atlas_Core.Atlas_Default +) +{ + container page1 (class: 'flex-column') { + use fragment SectionCard { + dynamictext custTitle (content: 'Customers', rendermode: H4, class: 'card-title') + dynamictext custBody (content: 'Recent customer activity') + } + use fragment SectionCard { + dynamictext ordTitle (content: 'Orders', rendermode: H4, class: 'card-title') + datagrid ordGrid (datasource: database MyModule.Order) { } + } + } +}; +``` + +The `slot` marker is resolved at expansion — `describe page` shows the fully +wrapped tree (`card1 > cardBody > custTitle, custBody`), and `mx check` is clean. +The slot name is optional (defaults to `content`); a fragment supports one slot. +Use `as prefix_` when the wrapper's *own* widget names would collide across uses +(the payload keeps the names you give it). For a fixed, content-invariant group +(a footer, a button pair) a plain slotless fragment is still the right tool. + +**Binding data and behaviour (experimental).** A slot varies *what widgets* go +inside; typed **parameters** vary *which entity* and *which microflow*. Declare a +`datasource` and/or `action` parameter and the card becomes a real component: + +```mdl +define fragment EntityCard($data: datasource, $onOpen: action) as { + container card1 (designproperties: ['Card style': on]) { + listview lv (datasource: $data) { + slot content + actionbutton open (caption: 'Open', action: $onOpen, buttonstyle: primary) + } + } +}; +use fragment EntityCard ($data: database Sales.Order, $onOpen: microflow Sales.Open) { + dynamictext cardTitle (content: 'Orders', rendermode: H4, class: 'card-title') +} +``` + +Atlas **building blocks** can't declare params, but `use building block` takes +rebind overrides that rewrite the block's outermost datasource / first button: + +```mdl +use building block Atlas_Web_Content.List_Cards + (datasource: database Sales.Order, action: microflow Sales.Open) as orders_; +``` + +For a binding the override rule can't reach, copy the block in (`as prefix_`) and +`alter page … set datasource/action on prefix_widget`. + +### Worked example — `Pageheader` + +`describe building block Atlas_Web_Content.Pageheader`: + +``` +{ + container container1 (Class: 'pageheader', DesignProperties: ['Item gap': 'None']) { + dynamictext text40 (Content: 'Page header title', RenderMode: H1, Class: 'pageheader-title') + dynamictext text39 (Content: 'Supporting text', RenderMode: Paragraph, Class: 'pageheader-subtitle', + DesignProperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']]) + } +} +``` + +Mirror: + +```mdl +create page MyModule.CustomersHeaderDemo +( + title: 'Customers', + layout: Atlas_Core.Atlas_Default +) +{ + container pageHeader (class: 'pageheader', designproperties: ['Item gap': 'None']) { + dynamictext headerTitle (content: 'Customers', rendermode: H1, class: 'pageheader-title') + dynamictext headerSubtitle + ( + content: 'All active accounts', + rendermode: Paragraph, + class: 'pageheader-subtitle', + designproperties: ['Color': 'Detail color', 'Spacing': ['margin-bottom': 'None']] + ) + } +}; +``` + +### Block → screen map (which block to reach for) + +| You want | Mirror this block | +|---|---| +| A titled surface panel | `Card` / `Card_Action` (with a trailing action) / `Card_WithImage` | +| A page title + subtitle band | `Pageheader` (+ `_WithBack` / `_WithControls` / `_WithSearch`) | +| A big splash header | `Heroheader` (+ `_Background` / `_WithAction`) | +| A vertical / horizontal form | `Form_Vertical*` / `Form_Horizontal*` | +| A card/list feed | `List_Cards`, `List_WithImage`, `ListItem_*` | +| A master list + detail pane | `Master_Detail` | +| An activity/history feed | `Timeline` / `Timeline_WithImage` | +| A multi-step flow | `Wizard_Arrow` / `Wizard_Circle` (+ their `_Step`) | +| An inline notice | `Alert`, `AlertIcon` (+ `_WithAction`) | +| A path/breadcrumb trail | `Breadcrumb` / `Breadcrumb_Underline` | + +--- +## Atlas appearance vocabulary — classes & design properties + +Atlas exposes its whole appearance system through the styling channels mxcli can +write today: raw `class:` strings and typed `designproperties:`. **Reach for these +before writing custom CSS.** + +### The cheat-sheet + +Apply via `class:` on any widget (space-join several: `class:'card flex-column'`). + +| Concern | Atlas classes | +|---|---| +| **Cards** | `card`, `cards` (+ Card-style variants) — real CSS, `.card` is ~19 rules | +| **Backgrounds** | `background-{default,main,primary,secondary,success,warning,danger}` | +| **Buttons** | `btn-{primary,secondary,success,warning,danger}`, `btn-{lg,sm,bordered,block,icon-right,icon-top}` | +| **Flex / align** | `flex-{row,column,nowrap,items-grow,items-shrink}`, `align-x-{left,center,right,between,around,evenly}`, `align-y-*` | +| **Spacing utils** | `spacing-{outer,inner}-{top,right,bottom,left}` (+ `-medium` / `-large` / `-none` sizes) | +| **Borders / overflow** | `div-border-toggle-{all,top,…,none}`, `div-overflow-{auto,hidden,visible}` (+ border radius/color/style/width) | +| **Elevation** | `Shadow` toggle | +| **Data grids** | `datagrid-{bordered,hover,striped,lined,lg,sm}` | +| **Group boxes** | `groupbox-{primary,danger,secondary,callout}` | + +Source: `atlas_core/web/design-properties.json` (verified in-project). To see what a +specific widget offers, run `show design properties` / `describe styling` +(`theme-styling`). + +### When to reach for each + +- **`card` / `Card style`** — any titled surface panel. This is the workhorse; a + dashboard is mostly cards on a `background-main` page. +- **`background-primary` / `background-success` / …** — coloured section/hero/status + surfaces. These resolve to your **retuned brand tokens** (Layer 1), so a hero band + set to `background-primary` turns *your* brand colour automatically. +- **`btn-*`** — prefer `buttonstyle: primary` on `actionbutton` for the semantic + style; add `btn-lg` / `btn-bordered` / `btn-block` as classes for size and shape. +- **`flex-row` / `flex-column` + `align-x-*` / `align-y-*`** — layout inside a + container without a `layoutgrid`. `flex-row` + `align-x-between` is the standard + "title on the left, action on the right" header row. +- **`spacing-inner-*` / `spacing-outer-*`** — padding/margin without inline `style:`. +- **`datagrid-*`** — reach for these on data grids before overriding grid CSS. +- **`groupbox-*`** — callouts / grouped sections with a semantic tint. + +### Typed design properties — the alternative channel + +Atlas building blocks use **both** channels side by side. The typed channel is what +Studio Pro's Appearance tab reads, so mirror it when you want the block to round-trip +cleanly into Studio Pro. Common mappings: + +| Class-style | Typed design-property equivalent | +|---|---| +| `class:'card'` | `designproperties: ['Card style': on]` | +| `class:'background-primary'` | `designproperties: ['Background color': 'Brand Primary']` | +| `class:'flex-column'` | `designproperties: ['Flex container': 'Vertical (column)']` | +| `class:'flex-row'` | `designproperties: ['Flex container': 'Horizontal (row)']` | +| `class:'align-x-center'` | `designproperties: ['Align items X': 'Center']` | +| `class:'Shadow'` | `designproperties: ['Shadow': 'None' / 'Small' / …]` | +| spacing utilities | `designproperties: ['Spacing': ['margin-bottom': 'L', 'padding-top': 'S']]` | + +**Both channels render identically at runtime** — raw `class:` is sufficient for the +visual result today. The typed channel matters for Studio Pro round-trip and is the +more idiomatic form to mirror from a `describe building block`. Notes: +- Design-property **keys are case-sensitive** — match the `describe` output exactly. +- Compound properties (Spacing, Border) take a **nested list**: + `['Spacing': ['margin-top': 'Large', 'margin-bottom': 'None']]`. +- **Never** put inline `style:` on a `dynamictext` — it crashes MxBuild. Use `class:` + or wrap in a styled `container`. (`theme-styling`.) + +--- diff --git a/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md b/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md new file mode 100644 index 000000000..5eda3d309 --- /dev/null +++ b/.claude/skills/mendix/atlas-design/reference/dark-mode-and-charts.md @@ -0,0 +1,125 @@ +# Charts, dark mode and widget overrides + +Supporting reference for [atlas-design](../SKILL.md). + +## Charts — a dataviz-grade theme for the Mendix chart widgets + +Out of the box the chart widgets (Column / Bar / Area / Pie / Line) render **raw +Plotly defaults**: one flat colour, a floating mode-bar, wide margins, heavy +gridlines, a white paper background. That is the single biggest "not a real product" +tell. Three Plotly hooks — barely used by generated apps — turn them into designed +charts. All three are **plain JSON strings** (no Mendix expression quoting). + +| Property | Plotly layer | Use it for | +|---|---|---| +| `customLayout` | `layout` | transparent `paper_bgcolor` + `plot_bgcolor`, system font, `#8a94a6` ticks, tight `margin`, faint `gridcolor`, `zeroline:false` / `showline:false`, dark `hoverlabel` | +| `customConfigurations` | `config` | `{"displayModeBar":false,"responsive":true}` — removes the floating toolbar | +| `customSeriesOptions` (per series; chart-level on Pie) | trace | brand colour, `marker.cornerradius` (rounded bars), `line.shape:"spline"` + translucent `fillcolor` (area), Pie colour array + white inside labels | + +**The key trick — transparent background = theme-agnostic charts.** Set +`paper_bgcolor` and `plot_bgcolor` to `rgba(0,0,0,0)`; the plot inherits whatever +panel it sits on, so **one config is correct in both light and dark** with zero +per-theme CSS. Pair it with a neutral tick colour (`#8a94a6`) that reads on either +background. Always kill the white paper **and** the mode-bar — the two ugliest +defaults. + +Ready-made `customLayout` (transparent, themed): +```json +{ + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(0,0,0,0)", + "font": { "family": "system-ui, -apple-system, 'Segoe UI', sans-serif", "color": "#8a94a6" }, + "margin": { "t": 8, "r": 8, "b": 32, "l": 40 }, + "xaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "yaxis": { "gridcolor": "rgba(138,148,166,0.15)", "zeroline": false, "showline": false }, + "hoverlabel": { "bgcolor": "#1a2129", "font": { "color": "#ffffff" } } +} +``` + +`customConfigurations` (kill the mode-bar): `{ "displayModeBar": false, "responsive": true }` + +`customSeriesOptions` per type: +```jsonc +// Column / Bar — brand colour + rounded corners +{ "marker": { "color": "#2b5170", "cornerradius": 6 } } +// Area — spline curve + translucent fill +{ "line": { "shape": "spline", "color": "#2b5170" }, "fill": "tozeroy", "fillcolor": "rgba(43,81,112,0.15)" } +// Pie (chart-level) — colour array + white inside labels +{ "marker": { "colors": ["#2b5170", "#4a7a5c", "#c9a227", "#a13a2c"] }, "insidetextfont": { "color": "#ffffff" } } +``` + +Swap the hex values for your brand palette (the same values you set in the Layer-1 +scaffold). The generic `dataviz` skill is the HTML/React analogue of this — same +"kill the defaults, one theme-agnostic config, brand the series" philosophy. + +**Chart gotchas** are in the [gotchas catalog](#gotchas-catalog). All chart types +(incl. Line/Bubble/Heatmap/TimeSeries) are MDL-authorable today — see +`mdl-examples/doctype-tests/34-chart-widget-examples.mdl` and `custom-widgets`. + +--- +## Optional dark-mode Atlas-widget overrides + +Paste into `main.scss` (Layer 2), after the `@import`s. Replace the token +placeholders with your dark palette. Popovers/modals render at ``, so the +popover + modal block must **not** be scoped to your app class — keep it global. + +```scss +// --- Dark palette tokens (TODO: set these) ---------------------------------- +$dk-surface: #1a2129; // panel / row background +$dk-surface-2: #232c37; // header / chip background +$dk-ink: #e6ebf1; // primary text +$dk-ink-mut: #9aa6b4; // muted text +$dk-border: #2f3a47; // hairline + +// Wrap in the media query for a dual-theme app; DELETE the @media line (and its +// closing brace) for a committed dark-only app to make these unconditional. +@media (prefers-color-scheme: dark) { + + // Form controls: text input / textarea / combobox field + .form-control, + .mx-textarea textarea, + .form-control input { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid: rows, headers, filter chips + .mx-datagrid table, .mx-datagrid tr, .mx-datagrid th, .mx-datagrid td { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + .filter-selector-button { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; + } + + // Datagrid dropdown filter: kill the hardcoded white scroll-fade gradient + .widget-dropdown-filter-menu { + background-image: none; background-color: $dk-surface; + } + .widget-dropdown-filter-menu * { color: $dk-ink; } + + // Accordion / Fieldset + .mx-groupbox, .mx-groupbox-header, fieldset, legend { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; + } + + // TreeNode: expanded child rows carry a WHITE card bg — let the panel show through + .mx-treenode, .mx-treenode .mx-treenode-content { + background: transparent; color: $dk-ink; + } +} + +// Popovers / modals render at — theme these GLOBALLY (unscoped). +// Combobox / tooltip / dropdown-filter popovers and edit popups (.mx-window / +// .modal-content) live outside your app class, so a scoped selector misses them. +.mx-window-content, .modal-content, .mx-window-header, .mx-tooltip, .mx-combobox-menu { + background: $dk-surface; color: $dk-ink; border-color: $dk-border; +} +.mx-window-content .form-control, .modal-content .form-control { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +.mx-window .btn-default, .modal-content .btn-default { + background: $dk-surface-2; color: $dk-ink; border-color: $dk-border; +} +// Charts: DON'T style them here — use the transparent customLayout (above). +``` + +--- diff --git a/.claude/skills/mendix/create-page/SKILL.md b/.claude/skills/mendix/create-page/SKILL.md index 26fce2ba6..a48627fcb 100644 --- a/.claude/skills/mendix/create-page/SKILL.md +++ b/.claude/skills/mendix/create-page/SKILL.md @@ -5,6 +5,22 @@ description: "CREATE PAGE syntax reference — parameters, variables, layouts, a # CREATE PAGE - MDL Syntax Guide +## Reference files + +`SKILL.md` covers page structure, the syntax, and what does not work. The bulk is +next door: + +- [`reference/widgets.md`](reference/widgets.md) — the widget catalogue: every + supported widget with its properties and the exact MDL spelling. **Look a widget + up here before writing it**; guessing a property name is the most common way a + page fails to build. +- [`reference/examples.md`](reference/examples.md) — complete pages, end to end, + to copy and adapt rather than assemble from parts. + +For the widgets *this project actually has* — including any marketplace or custom +ones — read the generated `widgets` skill instead +(`.ai-context/skills/widgets/SKILL.md`). + ## Overview Guide for writing CREATE PAGE statements in Mendix Definition Language (MDL). @@ -210,973 +226,6 @@ Notes: - Keyword-like names (`Right`, `Left`, `Content`) are accepted. - `describe page` emits `placeholder` blocks for multi-placeholder pages so they round-trip. -## Supported Widgets - -### DYNAMICTEXT Widget - -Display dynamic or static text: - -```sql --- Simple text -dynamictext heading (content: 'Heading Text', rendermode: H2) - --- Text bound to page parameter attribute (use $ParamName.Attribute) --- This preserves the parameter reference for pages with multiple parameters of the same type -dynamictext productName (content: '$Product.Name', rendermode: H3) - --- Explicit template with page parameter binding -dynamictext greeting (content: 'Welcome, {1}!', contentparams: [{1} = $Customer.Name]) - --- Template with attribute from current DataView context (simple attribute name) -dynamictext email (content: 'Email: {1}', contentparams: [{1} = Email]) - --- Bind directly to an attribute of the surrounding DataView/ListView/Gallery --- entity. `Attribute: X` is shorthand for `content: '{1}', contentparams: [{1} = X]`. -dynamictext title (Attribute: Title) -``` - -**ContentParams Reference Types:** -| Syntax | Context | Example | -|--------|---------|---------| -| `$ParamName.Attr` | Page parameter attribute | `$Product.Name` | -| `AttrName` | Current DataView/Gallery entity | `Name`, `Email` | -| `'literal'` | String literal expression | `'Hello'` | - -**Formatting a parameter (Decimal / DateTime / Enum):** append a `format (…)` -block to a content parameter. Without it, a Decimal renders with the platform -default (e.g. `5068.38000000`). - -```sql --- Decimal: 2 decimals + thousands separator -> "5,068.38" -dynamictext amt (content: '{1}', contentparams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)]) - --- DateTime: date + time, or a custom pattern -dynamictext due (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: DateTime)]) -dynamictext day (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: Custom, customDateFormat: 'dd-MM-yyyy')]) -``` - -| Format key | Applies to | Values | -|------------|-----------|--------| -| `decimalPrecision` | Decimal / Float | a non-negative integer | -| `groupDigits` | Decimal / Float | `true` \| `false` | -| `dateFormat` | DateTime | `Date` \| `DateTime` \| `Time` \| `Custom` | -| `customDateFormat` | DateTime | a pattern string, requires `dateFormat: Custom` | -| `enumFormat` | Enumeration | `Text` \| `Image` | - -> The **`format` keyword is required** — a bare `(…)` after the value is -> ambiguous with a function call. Putting a format key at the **widget** level -> (e.g. `dynamictext x (…, decimalPrecision: 2)`) is an error (MDL-WIDGET18): -> formatting is per-parameter, so it must go inside the `contentparams` block. - -> **Never leave a `{N}` placeholder unbound.** `content: '{1}'` with no -> `Attribute:`/`ContentParams:` is an orphaned template — `mxcli check` rejects it -> (MDL-WIDGET04), MxBuild fails with CE0720, and Studio Pro throws a -> NullReferenceException when the widget is opened. Bind every placeholder, or use -> a plain static `content: 'text'`. - -### ACTIONBUTTON Widget - -Create a button with action binding: - -```sql -actionbutton widgetName (caption: 'Caption', action: ACTION_TYPE [, buttonstyle: style] [, icon: 'Module.IconCollection.IconName']) -``` - -`icon:` names an icon inside an icon collection — `Module.Collection.IconName`, -e.g. `'Atlas_Core.Atlas_Filled.pencil'`. Browse what a project has with -`show icon collection` and `describe icon collection Atlas_Core.Atlas_Filled`. - -A wrong icon name is a **build error** (CE1613, *"The selected custom icon … no -longer exists"*), so check it before building: - -```bash -mxcli check script.mdl -p app.mpr --references -``` - -That resolves every icon reference against the project's collections and -suggests near matches for a typo. It needs `-p` — the collections are documents -in the project, so a plain `mxcli check` cannot see them. - -Use `linkbutton` instead of `actionbutton` for a button rendered as a link (same -properties). Both accept an `icon:` — an **icon-collection** reference, e.g. -`icon: 'Atlas_Core.Atlas_Filled.pencil'` (the modern Atlas icon set). The name -must exist in the icon collection or MxBuild rejects it (CE1613). - -**Find valid icon names** — don't guess (icons have non-obvious names: it's -`add`, not `plus`). List them: - -``` -show icon collections -- the project's icon sets -describe icon collection Atlas_Core.Atlas_Filled -- every icon + its reference form -``` - -**Action Bindings:** -- `action: save_changes` - Save changes to object -- `action: save_changes close_page` - Save and close page -- `action: cancel_changes` - Cancel changes -- `action: close_page` - Close the page -- `action: delete` - Delete object -- `action: microflow Module.MicroflowName` - Call microflow -- `action: microflow Module.MicroflowName(Param: $value)` - Call microflow with parameters -- `action: nanoflow Module.NanoflowName` - Call nanoflow (client-side) -- `action: nanoflow Module.NanoflowName(Param: $value)` - Call nanoflow with parameters -- `action: show_page Module.PageName` - Navigate to page -- `action: show_page Module.PageName(Param: $value)` - Navigate with parameters -- `action: show_page Module.PageName($Param = $value)` - Also accepted (microflow-style) -- `action: create_object Module.Entity then show_page Module.PageName` - Create and navigate -- **A `show_page` argument must be the context object.** Mendix takes the page - argument from the enclosing data widget, so the only spellings that mean - anything are `$currentObject` or the name of the variable that widget is bound - to (`datasource: $Customer` → `(Customer: $Customer)` is fine). Naming any other - variable is refused as **MDL-PAGEARG01** — it used to be accepted and silently - opened the page with the context object anyway. To open a page with something - else, call a microflow that shows it. - -**Button Styles:** `default`, `primary`, `success`, `info`, `warning`, `danger`, `inverse` -- Case-insensitive (`primary` and `Primary` both work). -- These are the only values Mendix recognizes — anything else (a typo, or `secondary`/`link`, which Mendix has no button style for) is rejected by `mxcli check` (MDL-WIDGET02). Previously an unknown value was silently rendered as `btn-default`. - -**Examples:** -```sql --- Save with style -actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - --- Navigate with parameter (inside DATAVIEW) -actionbutton btnEdit (caption: 'Edit', action: show_page Module.EditPage(Product: $Product)) - --- Navigate with $currentObject (inside DATAGRID column) -actionbutton btnEdit (caption: 'Edit', action: show_page Module.EditPage(Product: $currentObject)) - --- Call microflow with page/dataview parameter -actionbutton btnProcess (caption: 'Process', action: microflow Module.ACT_Process(Order: $Order), buttonstyle: success) - --- Call microflow with $currentObject (inside DATAGRID/LISTVIEW column) -actionbutton btnDelete (caption: 'Delete', action: microflow Module.ACT_Delete(Target: $currentObject), buttonstyle: danger) - --- Create object and show page -actionbutton btnNew (caption: 'New', action: create_object Module.Product then show_page Module.Product_Edit, buttonstyle: primary) -``` - -**Using `$currentObject`:** -Use `$currentObject` inside DATAGRID, LISTVIEW, or GALLERY columns to reference the current row's object. This is typically used in columns with `ShowContentAs: customContent` for action buttons. - -### LISTVIEW Specialization Templates - -A List View over a **generalization** can render a different body per -specialization. The template is identified by the entity it renders — it has no -name, which is why the keyword takes `for` and a qualified entity: - -```sql -listview vehicleListView (datasource: database from Pages.Vehicle) { - -- the default body: used for an object no template matches - dynamictext defaultVehicle (content: '{1} {2}', contentparams: [{1} = Brand, {2} = Model]) - - template for Pages.Bus { - dynamictext busLabel (content: 'Bus, capacity {1}', contentparams: [{1} = PassengerCapacity]) - } - template for Pages.Truck { - dynamictext truckLabel (content: 'Truck, max load {1} kg', contentparams: [{1} = MaxLoadKg]) - } -} -``` - -Rules: - -- The entity must be the list view's entity **or a specialization of it**. A - template for an unrelated entity can never match, so it is refused. -- **At most one template per entity.** -- Templates keep their **source order** — Mendix stores and matches in that - order, so it is authored, not derived, and DESCRIBE emits them as stored. -- Inside a template the context object **is the specialization**, so an - attribute only that specialization has resolves there (`PassengerCapacity` on - `Pages.Bus` above, which `Pages.Vehicle` does not have). - -Do not confuse this with a **Gallery's** `template `, which is a named -content slot, not a per-specialization body. - -### LINKBUTTON Widget - -Similar to ActionButton but rendered as link: - -```sql -linkbutton linkName (caption: 'Caption', action: ACTION_TYPE) -``` - -### LAYOUTGRID Widget - -Create responsive grid layout: - -```sql -layoutgrid gridName { - row rowName { - column colName (desktopwidth: 8) { - -- Nested widgets - } - column col2 (desktopwidth: 4) { - -- Nested widgets - } - } -} -``` - -**Column Width Properties:** - -| Property | Values | Default | Description | -|----------|--------|---------|-------------| -| `desktopwidth` | 1-12 or `autofill` | `autofill` | Desktop column width | -| `tabletwidth` | 1-12 or `autofill` | auto | Tablet column width | -| `phonewidth` | 1-12 or `autofill` | auto | Phone column width | - -```sql -column col1 (desktopwidth: 8, tabletwidth: 6, phonewidth: 12) { ... } -``` - -Example: -```sql -layoutgrid mainGrid { - row row1 { - column colMain (desktopwidth: 8) { - dynamictext heading (content: 'Main Content', rendermode: H3) - } - column colSide (desktopwidth: 4) { - dynamictext sideHeading (content: 'Sidebar', rendermode: H3) - } - } -} -``` - -### DATAGRID Widget - -Display list of objects using DataGrid widget: - -```sql -datagrid gridName ( - datasource: database from Module.Entity where [condition] sort by attributename asc|desc, - selection: Multi -) { - column colName (attribute: attributename, caption: 'Label') -} -``` - -> **Reserved keyword column names:** If the attribute name is an MDL reserved keyword (e.g. `Status`, `Type`), you must quote the attribute value and use a distinct widget name for the column: -> ```sql -> column colStatus (attribute: "Status", caption: 'Status') -> column colType (attribute: "Type", caption: 'Type') -> ``` -> Writing `COLUMN Status (attribute: Status)` fails silently — `Status` and `Type` are parsed as keywords. Always use a `col`-prefixed widget name when the attribute name is reserved. - -**Column Properties:** - -| Property | Values | Default | Description | -|----------|--------|---------|-------------| -| `attribute` | attribute name | (required) | Attribute binding | -| `caption` | string | attribute name | Column header text | -| `Alignment` | `left`, `center`, `right` | `left` | Text alignment | -| `WrapText` | `true`, `false` | `false` | Wrap text in cell | -| `Sortable` | `true`, `false` | `true` (if attribute), `false` (if not) | Can sort column | -| `Resizable` | `true`, `false` | `true` | Can resize column | -| `Draggable` | `true`, `false` | `true` | Can reorder column | -| `Hidable` | `yes`, `hidden`, `no` | `yes` | Can hide column | -| `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | Column width mode | -| `Size` | integer (px) | `1` | Width in pixels (when `ColumnWidth: manual`) | -| `visible` | expression string | `true` | Column-level visibility — hides/shows the whole column, so use page variables, NOT `$currentObject` (per-object widget visibility is different — see "Conditional Visibility and Editability") | -| `DynamicCellClass` | expression string | (empty) | Dynamic CSS class per cell | -| `tooltip` | text string | (empty) | Cell tooltip text | - -Only non-default column properties appear in `describe page` output. - -**Dynamic-text columns (`ShowContentAs: dynamicText`):** a column can render its cell as a formatted text template instead of a bare attribute — the same `Content` / `ContentParams` / `format (...)` syntax as a `dynamictext` widget (see below). The column needs no `Attribute`; give it a `Caption` for the header. - -```sql -datagrid gridName (datasource: database from Module.Entity) { - -- Decimal with 2 decimals + thousands separator: renders e.g. "Amt: -1,234.50" - column amount ( - Caption: 'Amount', - ShowContentAs: dynamicText, - Content: 'Amt: {1}', - ContentParams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)] - ) - column due (attribute: DueOn, caption: 'Due') -} -``` - -The `format (...)` block accepts `decimalPrecision`, `groupDigits`, `dateFormat` (`Date` / `DateTime` / `Time` / `Custom`), `customDateFormat`, and `enumFormat` (`Text` / `Image`). Formatting is applied by Mendix only to **attribute-bound** parameters — bind the bare attribute (`Amount`), not `toString(...)`. - -```sql -column colPrice ( - attribute: Price, caption: 'Unit Price', - Alignment: right, WrapText: true, - Sortable: false, Resizable: false, - Hidable: hidden, - ColumnWidth: manual, Size: 150, - DynamicCellClass: 'if($currentObject/Price > 100) then ''highlight'' else '''' ', - tooltip: 'Price in USD' -) -``` - -**Associated-attribute columns:** - -A column can bind an attribute *over an association*, not just an own-entity -attribute — use a bare association path `Assoc/Attr`: - -```sql -datagrid dgOrders (datasource: database from Sales.Order) { - column colNumber (attribute: Number, caption: 'Order #') - column colCustomer (attribute: Order_Customer/Name, caption: 'Customer') -- associated attr -} -``` - -The association name is bare (resolved against the grid's entity module); -multi-hop paths (`A/B/Attr`) are supported. Module-qualified associations -(`Module.Assoc/Attr`) are **not** accepted — use the bare name. - -**Custom Content Columns:** - -Columns can contain nested widgets instead of attribute bindings. These build -correctly on the default engine (mxbuild-verified, 0 errors) — an earlier CE0463 -(column property ordering) was fixed: - -```sql -column colActions (caption: 'Actions') { - actionbutton btnView (caption: 'View', action: close_page) -} -``` - -**Supported Datasource Types:** - -| Syntax | Description | -|--------|-------------| -| `datasource: database from Module.Entity` | Direct database query | -| `datasource: $Variable` | Variable bound (requires DATAVIEW parent with entity) | -| `datasource: microflow Module.GetData` | Microflow datasource — no `()`, the name alone | -| `datasource: nanoflow Module.GetData` | Nanoflow datasource (client-side, no server roundtrip) — no `()` | -| `datasource: selection widgetName` | Listen to selection from another widget | -| `datasource: association path` | Retrieve by association from context (ByAssociation) | -| `datasource: $currentObject/Module.Assoc` | Sugar for `association` — same semantics, reads more naturally | - -**With WHERE and SORT BY (inline in DataSource):** -```sql -datagrid dgActive ( - datasource: database from Module.Product where [IsActive = true] sort by Name asc -) { - column colName (attribute: Name, caption: 'Name') - column colPrice (attribute: Price, caption: 'Price') -} -``` - -**Complex WHERE conditions:** -```sql -datagrid dgFiltered ( - datasource: database from Module.Product - where [IsActive = true and contains(Code, 'a') and Price > 10] or [Stock < 2] - sort by Name asc, Price desc -) { - column colName (attribute: Name, caption: 'Name') -} -``` - -**Paging Properties:** - -| Property | Values | Default | Description | -|----------|--------|---------|-------------| -| `PageSize` | Any positive integer | 20 | Number of rows per page | -| `Pagination` | `buttons`, `virtualScrolling`, `loadMore` | `buttons` | Paging mode | -| `PagingPosition` | `bottom`, `top`, `both` | `bottom` | Position of paging controls | -| `ShowPagingButtons` | `always`, `auto` | `always` | When to show paging buttons | - -```sql --- Paging buttons above and below, 25 rows per page -datagrid dgProducts ( - datasource: database Module.Product, - PageSize: 25, - PagingPosition: both -) { - column colName (attribute: Name, caption: 'Name') -} - --- Virtual scrolling (no paging buttons) -datagrid dgLargeList ( - datasource: database Module.Product, - PageSize: 50, - Pagination: virtualScrolling -) { - column colName (attribute: Name, caption: 'Name') -} -``` - -Only non-default paging properties appear in `describe page` output. - -### DATAVIEW Widget - -Display single object with nested input widgets: - -```sql -dataview dvName (datasource: $VariableName) { - -- Nested input widgets - textbox txtName (label: 'Name', attribute: Name) - textarea txtDescription (label: 'Description', attribute: description) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } -} -``` - -### Input Widgets - -Input widgets must be inside a DATAVIEW context. Use `attribute:` to bind to attributes: - -**TEXTBOX** - Single-line text input: -```sql -textbox txtName (label: 'Label', attribute: attributename) -``` - -**TEXTAREA** - Multi-line text input: -```sql -textarea txtDescription (label: 'Description', attribute: description) -``` - -**CHECKBOX** - Boolean checkbox: -```sql -checkbox cbActive (label: 'Active', attribute: IsActive) -``` - -**RADIOBUTTONS** - Boolean or enum selection: -```sql -radiobuttons rbStatus (label: 'Status', attribute: status) -``` - -**DATEPICKER** - Date/time selection: -```sql -datepicker dpCreated (label: 'Created Date', attribute: CreatedDate) -``` - -**COMBOBOX** - Combo box (pluggable widget): -```sql --- Enumeration mode (attribute is an enum type): -combobox cbCountry (label: 'Country', attribute: Country) - --- Association mode: bind a reference. Requires the option DataSource (the target --- entity whose objects fill the dropdown) AND a CaptionAttribute (display value). --- The reference can be given as `Association:` or, equivalently, `attribute:`. -combobox cmbCustomer (label: 'Customer', Association: Order_Customer, datasource: database MyModule.Customer, CaptionAttribute: Name) --- WRONG: `combobox (Association: X)` with no datasource — mxcli check errors --- MDL-WIDGET16 (Mendix would otherwise drop the binding → CE0642). -``` - -### DataView with Form Layout - -```sql -dataview dataView1 (datasource: $Customer) { - textbox txtName (label: 'Name', attribute: Name) - textbox txtEmail (label: 'Email', attribute: Email) - textarea txtAddress (label: 'Address', attribute: Address) - combobox cbStatus (label: 'Status', attribute: status) - checkbox cbActive (label: 'Active', attribute: IsActive) - datepicker dpCreated (label: 'Created', attribute: CreateDate) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } -} -``` - -**Form Orientation (label placement):** the DataView's Studio Pro "Form -Orientation" radio is stored as `LabelWidth` in BSON. Specify either form in MDL: - -```sql -dataview dv (datasource: $Customer, FormOrientation: Vertical) -- label above -dataview dv (datasource: $Customer, FormOrientation: Horizontal) -- label beside (default, LabelWidth=3) -dataview dv (datasource: $Customer, LabelWidth: 4) -- explicit, 0..12 columns of 12 -``` - -`LabelWidth: 0` ⇔ `FormOrientation: Vertical`. If both are given, `LabelWidth` wins. - -**Footer (`showFooter`):** a `footer { … }` block turns the footer on by itself, so -the property is only needed when the two would disagree: - -```sql -dataview dv (datasource: $Customer, showFooter: true) -- empty footer, shown -dataview dv (datasource: $Customer, showFooter: false) { -- widgets declared, hidden - footer f { dynamictext t (content: 'hidden') } -} -``` - -An explicit `showFooter` wins over the block in both directions, and hiding a footer -never discards its widgets. - -### GALLERY Widget - -Display items in card layout with selection and responsive columns: - -```sql -gallery galleryName ( - datasource: database from Module.Entity sort by Name asc, - selection: Single, - DesktopColumns: 3, - TabletColumns: 2, - PhoneColumns: 1 -) { - template template1 { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) - } -} -``` - -**With Filter:** -```sql -gallery productGallery (datasource: database Module.Product, selection: single) { - filter filter1 { - textfilter searchName (attribute: Name) - } - template template1 { - dynamictext prodName (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext prodCode (content: 'SKU: {1}', contentparams: [{1} = Code]) - } -} -``` - -### Filter Widgets - -Filter widgets are used inside GALLERY FILTER containers to enable search/filtering: - -**TEXTFILTER** - Text search filter: -```sql --- Simple binding to single attribute -textfilter searchName (attribute: Name) - --- Multiple attributes with explicit list -textfilter textFilter1 (attributes: [Module.Entity.Name, Module.Entity.Code, Module.Entity.Description]) - --- With filter type -textfilter textFilter1 (attributes: [Module.Entity.Description], filtertype: startsWith) -``` - -**FilterType Options:** -- `contains` (default) - Matches if attribute contains text -- `startsWith` - Matches if attribute starts with text -- `endsWith` - Matches if attribute ends with text -- `equal` - Exact match - -**NUMBERFILTER** - Numeric range filter: -```sql -numberfilter priceFilter (attributes: [Module.Entity.Price]) -``` - -**DATEFILTER** - Date range filter: -```sql -datefilter datefilter (attributes: [Module.Entity.CreateDate]) -``` - -**DROPDOWNFILTER** - Dropdown selection filter: -```sql -dropdownfilter statusFilter (attributes: [Module.Entity.Status]) -``` - -Filter by an **association** instead of an attribute — the options are the -associated objects. Giving the filter a `datasource:` (the OPTION list) selects -this mode; all three parts are required: - -```sql -column colCustomer (attribute: Order_Customer/Name, caption: 'Customer') { - dropdownfilter ddfCustomer ( - Association: Sales.Order_Customer, -- the reference on the GRID entity - datasource: database Sales.Customer, -- the option list (associated entity) - CaptionAttribute: Name -- what each option shows - ) -} -``` - -> **A column cannot bind the association itself.** `column c (attribute: Order_Customer)` -> is refused — Mendix has nowhere to store a reference in an attribute-typed widget -> property, and writing one anyway fails the build with CE1613 *"The selected attribute -> … no longer exists"*. To **show** a value from the associated object, traverse the -> reference (`attribute: Order_Customer/Name`); to **filter** by it, use the mode above. - -### NAVIGATIONLIST Widget - -Create a menu with action items: - -```sql -navigationlist navName { - item itemEdit (caption: 'Edit', action: show_page Module.EditPage(entity: $EntityParameter)) - item itemDelete (caption: 'Delete', action: delete) - item itemBack (caption: 'Back', action: close_page) -} -``` - -### SNIPPETCALL Widget - -Embed a reusable snippet: - -```sql --- Simple snippet call -snippetcall snippetName (snippet: Module.SnippetName) - --- With parameters -snippetcall actions (snippet: Module.EntityActions, params: {entity: $Param}) -``` - -**A parameter satisfied by the enclosing data context takes NO mapping.** Mendix -has no variable meaning "the surrounding context" — the correct model is the -*absence* of a mapping, which is what Studio Pro writes. So inside a DataView -bound to the parameter's entity, just omit `Params:`: - -```sql -dataview dvOrder (datasource: $Order) { - snippetcall scActions (snippet: MyModule.OrderActions) -- parameter comes from dvOrder -} -``` - -`params: {Order: $currentObject}` means the same thing and produces the same -(empty) mapping. Naming a real page parameter or variable produces a real -mapping, as expected: - -```sql -snippetcall scActions (snippet: MyModule.OrderActions, params: {Order: $Order}) -``` - -Omitting `Params:` where the context is a *different* entity is still an error — -nothing there can satisfy the parameter. - -### IMAGE / STATICIMAGE / DYNAMICIMAGE Widgets - -Display images on pages: - -```sql --- Image with dimensions (responsive by default) -image imgLogo (width: 200, height: 100) -staticimage imgBanner (width: 400, height: 120) - --- Dynamic image (from entity data source, e.g. inside a DataView) -dynamicimage imgProduct (width: 300, height: 200) - --- Image without explicit dimensions -image imgIcon -``` - -**Properties:** `width: integer`, `height: integer`, `AlternativeText: 'text'`, `WidthUnit: pixels | percentage | auto`, `HeightUnit: pixels | percentage | auto`, `Responsive: true | false`, `DisplayAs: fullImage | thumbnail | icon`, `class: 'css'`, `style: 'css'` - -#### Setting Image Source (PLUGGABLEWIDGET syntax) - -The IMAGE shorthand creates a pluggable Image widget. For advanced properties like image source, use PLUGGABLEWIDGET syntax: - -| Mode | Property | Use Case | -|------|----------|----------| -| `datasource: image` | `imageObject` | Dynamic image from entity (default) | -| `datasource: imageUrl` | `imageUrl: 'path'` | Static image from URL or file path | -| `datasource: icon` | `imageIcon` | Icon-based image | - -```sql --- Static image from file (logos, branding) -pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( - datasource: imageUrl, - imageUrl: 'img/logo.svg', - widthUnit: pixels, width: 48, - heightUnit: pixels, height: 48 -) - --- Update existing IMAGE via ALTER PAGE -alter page Mod.Home { - replace imgLogo with { - pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( - datasource: imageUrl, imageUrl: 'img/logo_dark.svg', - widthUnit: pixels, width: 48, heightUnit: pixels, height: 48 - ) - } -}; -``` - -For theme images, use paths relative to `theme/web/` (e.g., `img/logo.svg` → `theme/web/img/logo.svg`). - -**A per-row image URL comes from the entity, two ways.** `imageUrl` is a text -template, so it takes either spelling: - -```sql --- named placeholder: shortest form for a single attribute -pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( - datasource: imageUrl, imageUrl: '{PictureUrl}' -) - --- numbered placeholders + contentparams: needed for several values, or a format block -pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( - datasource: imageUrl, - imageUrl: '{1}/{2}', contentparams: [{1} = BaseUrl, {2} = PictureUrl] -) -``` - -Every `{N}` must have a matching parameter — Mendix rejects a shortfall with -`CE0720` ("place holder index N is greater than …, the number of parameter(s)"). -Parameters with no `{N}` to fill are reported by MDL-WIDGET21 rather than -dropped in silence. - -### Buttons Have Visibility, Not Editability - -`editable:` only exists on **input** widgets — Mendix gives exactly eleven page -widgets an editability setting (textbox, textarea, checkbox, datepicker, -dropdown, radiobuttons, referenceselector, inputreferencesetselector, -filemanager, imageuploader, and dataview). No button of any kind has one, so -`editable:` on a button is reported by MDL-WIDGET20 and does nothing. - -To disable a button conditionally, hide it instead — buttons do support -conditional visibility — or put the condition in the microflow it calls: - -```sql -actionbutton btnSubmit ( - caption: 'Submit', action: microflow Mod.ACT_Submit, - visible: [$currentObject/Status = Mod.Status.Draft] -) -``` - -### CONTAINER / CUSTOMCONTAINER Widgets - -Generic container for grouping widgets. `customcontainer` is an alias for `container` (both map to `Forms$DivContainer`): - -```sql --- Basic container with CSS class -container card1 (class: 'card', style: 'padding: 16px;') { - dynamictext title (content: 'Card Title', rendermode: H4) - dynamictext body (content: 'Card body content') -} - --- Container with design properties -container spaced1 (designproperties: ['Spacing top': 'Large', 'Full width': on]) { - dynamictext text1 (content: 'Spaced full-width content') -} - --- Nested containers with combined styling -customcontainer outer1 (class: 'section') { - container inner1 (class: 'card', designproperties: ['Spacing top': 'Medium']) { - dynamictext text1 (content: 'Nested content') - } -} -``` - -**Clickable container (On click action).** A container can trigger an action when -clicked — use `OnClick:` (or the equivalent `Action:` keyword) with any client -action (`microflow`, `nanoflow`, `show_page`, `save_changes`, …): - -```sql -container card1 (OnClick: microflow MyModule.ACT_OpenDetails, class: 'clickable-card') { - dynamictext title (content: 'Open details') -} -``` - -This maps to the Mendix Container's **On click** event (`Forms$DivContainer.OnClickAction`). -A container with no `OnClick:`/`Action:` is non-clickable (a no-op action), exactly as in Studio Pro. - -**Prefer a clickable container over an `actionbutton` when the trigger needs child -widgets or a context argument.** An `actionbutton` can't contain child widgets and -maps only page-context object parameters — so a tile showing a digit over a label, -or a card that opens a specific object, is best built as a `container` with -`OnClick:`. The container's `OnClick:` takes the same `(Param: …)` argument syntax -as an `actionbutton`'s `action:`: - -```sql --- Rich, parameterised trigger: a card that opens the object it represents -container tileCard (OnClick: microflow MyModule.ACT_Open(Item: $currentObject), class: 'tile') { - dynamictext tileValue (content: '4') - dynamictext tileLabel (content: '4 LEFT', class: 'tile-label') -} -``` - -Note the Mendix limit this works around: a button (or `OnClick`) can map **object** -parameters from the page context but **cannot pass a literal argument**. To vary a -literal per trigger (e.g. a 1–9 number pad), make one real microflow and a thin -wrapper per value (`ACT_Set1`…`ACT_Set9`), each calling the shared implementation. - -### FOOTER Widget - -Container for form action buttons: - -```sql -footer footerName { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) -} -``` - -### HEADER Widget - -Container for header content: - -```sql -header headerName { - dynamictext title (content: 'Form Title', rendermode: H3) -} -``` - -### CONTROLBAR Widget - -Control bar for data widgets: - -```sql -controlbar controlBar1 { - actionbutton btnNew (caption: 'New', action: create_object Module.Entity then show_page Module.EditPage, buttonstyle: primary) -} -``` - -### Charts (Charts.mpk — ColumnChart / BarChart / AreaChart / PieChart) - -Charts are pluggable widgets whose data lives in one or more `series` object-list -items. Each series binds a datasource (an OQL **view entity** is the natural feed — -one row per category) and picks X/Y attributes on that datasource. Requires the -Charts widget installed (`widgets/Charts.mpk`); run `mxcli widget init -p app.mpr`. - -```sql --- Aggregated view entity = the chart's data source -create view entity Sales.ByRegion (Region: string(100), Total: decimal) as - select s.Region as Region, sum(s.Amount) as Total - from Sales.Sale as s group by s.Region; - -create page Sales.Dashboard (Title: 'Revenue', Layout: Atlas_Core.Atlas_Default) { - pluggablewidget 'com.mendix.widget.web.columnchart.ColumnChart' revenueChart { - series sRevenue ( - dataSet: static, - DataSource: database from Sales.ByRegion, -- or: staticDataSource: database from Sales.ByRegion - StaticXAttribute: Region, - StaticYAttribute: Total, - StaticName: 'Revenue' - ) - } -} -``` - -Notes: -- `DataSource:` inside a series is a friendly alias for `staticDataSource:` / - `dynamicDataSource:` (chosen by `dataSet`); either form works. -- X/Y attributes resolve against the **series' own** datasource entity, not the page. -- Add multiple `series ( ... )` blocks for multi-series charts. BarChart/AreaChart/ - PieChart use the same `series` shape. -- **CE0463 at `mx check`**: charts can report "widget definition changed" from - widget-version drift (embedded template vs the installed `Charts.mpk`), even for a - chart with no series. Clear it with **`mxcli docker check`/`build`**, which normalize - the widgets and preserve your storage format. - **Do NOT run bare `mx update-widgets` on an MPRv2 project** (one with an - `mprcontents/` folder — everything `mxcli new` creates). `mx update-widgets` rewrites - the project into the single-file v1 format and **deletes `mprcontents/`**, which - corrupts a git working tree, breaks a running `mxcli run --local` loop, and can leave - Studio Pro unable to open the project. `mxcli docker check`/`build` snapshot and - restore the v2 files around the normalization, so they are safe; raw - `mx update-widgets` is only safe on a v1 project (or on a throwaway copy used purely - for diagnosis). -- LineChart/BubbleChart/HeatMap/TimeSeries are **also MDL-authorable** (via the - `line`/`scalecolor` object-lists) — see - `mdl-examples/doctype-tests/34-chart-widget-examples.mdl` for working examples. - (They author on the modelsdk engine.) -- **Slider / RangeSlider**: set `showTooltip: false`. The tooltip calls React's - removed `findDOMNode` on Mendix 11, throwing "Could not render widget" on drag — a - runtime crash `mx check` cannot catch (only a running build does). See - `atlas-design` for the full runtime-verification rule. - -## Complete Examples - -### Customer Edit Page - -```sql -create or replace page CRM.CustomerEdit -( - params: { $Customer: CRM.Customer }, - title: 'Edit Customer', - layout: Atlas_Core.PopupLayout -) -{ - -- Wrap the form's DataView in a layout grid. Label width and input-control - -- width are expressed in Bootstrap grid columns and only render correctly - -- inside a layoutgrid → row → column. A DataView with input fields placed - -- directly on the page (no grid) is flagged by lint rule MPR010 / mxcli check. - layoutgrid mainGrid { - row row1 { - column col1 (desktopwidth: autofill) { - dataview dvCustomer (datasource: $Customer) { - textbox txtName (label: 'Name', attribute: Name) - textbox txtEmail (label: 'Email', attribute: Email) - textbox txtPhone (label: 'Phone', attribute: Phone) - checkbox cbActive (label: 'Active', attribute: IsActive) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } - } - } - } - } -} -``` - -### Order Overview Page - -```sql -create page Orders.OrderOverview -( - title: 'Orders', - layout: Atlas_Core.Atlas_Default -) -{ - layoutgrid mainGrid { - row row1 { - column col1 (desktopwidth: 12) { - dynamictext heading (content: 'Order Overview', rendermode: H2) - } - } - row row2 { - column col2 (desktopwidth: 12) { - datagrid dgOrders (datasource: database from Orders.Order sort by OrderDate desc) { - column colNumber (attribute: OrderNumber, caption: 'Order #') - column colDate (attribute: OrderDate, caption: 'Date') - column colTotal (attribute: TotalAmount, caption: 'Total') - } - } - } - } -} -``` - -### Master-Detail Page - -```sql -create page CRM.Customer_MasterDetail -( - title: 'Customer Management', - layout: Atlas_Core.Atlas_Default -) -{ - layoutgrid mainGrid { - row row1 { - -- Master list (left column) - column colMaster (desktopwidth: 4) { - dynamictext heading (content: 'Customers', rendermode: H3) - gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { - template template1 { - dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) - dynamictext email (content: '{1}', contentparams: [{1} = Email]) - } - } - } - - -- Detail form (right column) - column colDetail (desktopwidth: 8) { - dataview customerDetail (datasource: selection customerList) { - dynamictext detailHeading (content: 'Customer Details', rendermode: H3) - textbox txtName (label: 'Name', attribute: Name) - textbox txtEmail (label: 'Email', attribute: Email) - textbox txtPhone (label: 'Phone', attribute: Phone) - - footer footer1 { - actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) - actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) - } - } - } - } - } -} -``` - ## Modifying Existing Pages To make targeted changes to an existing page (change a label, add a field, remove a widget), use `alter page` instead of `create or replace page`. ALTER PAGE modifies the widget tree in-place, preserving properties that MDL doesn't model. diff --git a/.claude/skills/mendix/create-page/reference/examples.md b/.claude/skills/mendix/create-page/reference/examples.md new file mode 100644 index 000000000..95f2475fa --- /dev/null +++ b/.claude/skills/mendix/create-page/reference/examples.md @@ -0,0 +1,108 @@ +# Complete page examples + +Supporting reference for [create-page](../SKILL.md). + +## Complete Examples + +### Customer Edit Page + +```sql +create or replace page CRM.CustomerEdit +( + params: { $Customer: CRM.Customer }, + title: 'Edit Customer', + layout: Atlas_Core.PopupLayout +) +{ + -- Wrap the form's DataView in a layout grid. Label width and input-control + -- width are expressed in Bootstrap grid columns and only render correctly + -- inside a layoutgrid → row → column. A DataView with input fields placed + -- directly on the page (no grid) is flagged by lint rule MPR010 / mxcli check. + layoutgrid mainGrid { + row row1 { + column col1 (desktopwidth: autofill) { + dataview dvCustomer (datasource: $Customer) { + textbox txtName (label: 'Name', attribute: Name) + textbox txtEmail (label: 'Email', attribute: Email) + textbox txtPhone (label: 'Phone', attribute: Phone) + checkbox cbActive (label: 'Active', attribute: IsActive) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } + } + } + } + } +} +``` + +### Order Overview Page + +```sql +create page Orders.OrderOverview +( + title: 'Orders', + layout: Atlas_Core.Atlas_Default +) +{ + layoutgrid mainGrid { + row row1 { + column col1 (desktopwidth: 12) { + dynamictext heading (content: 'Order Overview', rendermode: H2) + } + } + row row2 { + column col2 (desktopwidth: 12) { + datagrid dgOrders (datasource: database from Orders.Order sort by OrderDate desc) { + column colNumber (attribute: OrderNumber, caption: 'Order #') + column colDate (attribute: OrderDate, caption: 'Date') + column colTotal (attribute: TotalAmount, caption: 'Total') + } + } + } + } +} +``` + +### Master-Detail Page + +```sql +create page CRM.Customer_MasterDetail +( + title: 'Customer Management', + layout: Atlas_Core.Atlas_Default +) +{ + layoutgrid mainGrid { + row row1 { + -- Master list (left column) + column colMaster (desktopwidth: 4) { + dynamictext heading (content: 'Customers', rendermode: H3) + gallery customerList (datasource: database from CRM.Customer sort by Name asc, selection: single) { + template template1 { + dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) + dynamictext email (content: '{1}', contentparams: [{1} = Email]) + } + } + } + + -- Detail form (right column) + column colDetail (desktopwidth: 8) { + dataview customerDetail (datasource: selection customerList) { + dynamictext detailHeading (content: 'Customer Details', rendermode: H3) + textbox txtName (label: 'Name', attribute: Name) + textbox txtEmail (label: 'Email', attribute: Email) + textbox txtPhone (label: 'Phone', attribute: Phone) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } + } + } + } + } +} +``` diff --git a/.claude/skills/mendix/create-page/reference/widgets.md b/.claude/skills/mendix/create-page/reference/widgets.md new file mode 100644 index 000000000..681eebda4 --- /dev/null +++ b/.claude/skills/mendix/create-page/reference/widgets.md @@ -0,0 +1,865 @@ +# Widget catalogue + +Supporting reference for [create-page](../SKILL.md). + +## Supported Widgets + +### DYNAMICTEXT Widget + +Display dynamic or static text: + +```sql +-- Simple text +dynamictext heading (content: 'Heading Text', rendermode: H2) + +-- Text bound to page parameter attribute (use $ParamName.Attribute) +-- This preserves the parameter reference for pages with multiple parameters of the same type +dynamictext productName (content: '$Product.Name', rendermode: H3) + +-- Explicit template with page parameter binding +dynamictext greeting (content: 'Welcome, {1}!', contentparams: [{1} = $Customer.Name]) + +-- Template with attribute from current DataView context (simple attribute name) +dynamictext email (content: 'Email: {1}', contentparams: [{1} = Email]) + +-- Bind directly to an attribute of the surrounding DataView/ListView/Gallery +-- entity. `Attribute: X` is shorthand for `content: '{1}', contentparams: [{1} = X]`. +dynamictext title (Attribute: Title) +``` + +**ContentParams Reference Types:** +| Syntax | Context | Example | +|--------|---------|---------| +| `$ParamName.Attr` | Page parameter attribute | `$Product.Name` | +| `AttrName` | Current DataView/Gallery entity | `Name`, `Email` | +| `'literal'` | String literal expression | `'Hello'` | + +**Formatting a parameter (Decimal / DateTime / Enum):** append a `format (…)` +block to a content parameter. Without it, a Decimal renders with the platform +default (e.g. `5068.38000000`). + +```sql +-- Decimal: 2 decimals + thousands separator -> "5,068.38" +dynamictext amt (content: '{1}', contentparams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)]) + +-- DateTime: date + time, or a custom pattern +dynamictext due (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: DateTime)]) +dynamictext day (content: '{1}', contentparams: [{1} = DueOn format (dateFormat: Custom, customDateFormat: 'dd-MM-yyyy')]) +``` + +| Format key | Applies to | Values | +|------------|-----------|--------| +| `decimalPrecision` | Decimal / Float | a non-negative integer | +| `groupDigits` | Decimal / Float | `true` \| `false` | +| `dateFormat` | DateTime | `Date` \| `DateTime` \| `Time` \| `Custom` | +| `customDateFormat` | DateTime | a pattern string, requires `dateFormat: Custom` | +| `enumFormat` | Enumeration | `Text` \| `Image` | + +> The **`format` keyword is required** — a bare `(…)` after the value is +> ambiguous with a function call. Putting a format key at the **widget** level +> (e.g. `dynamictext x (…, decimalPrecision: 2)`) is an error (MDL-WIDGET18): +> formatting is per-parameter, so it must go inside the `contentparams` block. + +> **Never leave a `{N}` placeholder unbound.** `content: '{1}'` with no +> `Attribute:`/`ContentParams:` is an orphaned template — `mxcli check` rejects it +> (MDL-WIDGET04), MxBuild fails with CE0720, and Studio Pro throws a +> NullReferenceException when the widget is opened. Bind every placeholder, or use +> a plain static `content: 'text'`. + +### ACTIONBUTTON Widget + +Create a button with action binding: + +```sql +actionbutton widgetName (caption: 'Caption', action: ACTION_TYPE [, buttonstyle: style] [, icon: 'Module.IconCollection.IconName']) +``` + +`icon:` names an icon inside an icon collection — `Module.Collection.IconName`, +e.g. `'Atlas_Core.Atlas_Filled.pencil'`. Browse what a project has with +`show icon collection` and `describe icon collection Atlas_Core.Atlas_Filled`. + +A wrong icon name is a **build error** (CE1613, *"The selected custom icon … no +longer exists"*), so check it before building: + +```bash +mxcli check script.mdl -p app.mpr --references +``` + +That resolves every icon reference against the project's collections and +suggests near matches for a typo. It needs `-p` — the collections are documents +in the project, so a plain `mxcli check` cannot see them. + +Use `linkbutton` instead of `actionbutton` for a button rendered as a link (same +properties). Both accept an `icon:` — an **icon-collection** reference, e.g. +`icon: 'Atlas_Core.Atlas_Filled.pencil'` (the modern Atlas icon set). The name +must exist in the icon collection or MxBuild rejects it (CE1613). + +**Find valid icon names** — don't guess (icons have non-obvious names: it's +`add`, not `plus`). List them: + +``` +show icon collections -- the project's icon sets +describe icon collection Atlas_Core.Atlas_Filled -- every icon + its reference form +``` + +**Action Bindings:** +- `action: save_changes` - Save changes to object +- `action: save_changes close_page` - Save and close page +- `action: cancel_changes` - Cancel changes +- `action: close_page` - Close the page +- `action: delete` - Delete object +- `action: microflow Module.MicroflowName` - Call microflow +- `action: microflow Module.MicroflowName(Param: $value)` - Call microflow with parameters +- `action: nanoflow Module.NanoflowName` - Call nanoflow (client-side) +- `action: nanoflow Module.NanoflowName(Param: $value)` - Call nanoflow with parameters +- `action: show_page Module.PageName` - Navigate to page +- `action: show_page Module.PageName(Param: $value)` - Navigate with parameters +- `action: show_page Module.PageName($Param = $value)` - Also accepted (microflow-style) +- `action: create_object Module.Entity then show_page Module.PageName` - Create and navigate +- **A `show_page` argument must be the context object.** Mendix takes the page + argument from the enclosing data widget, so the only spellings that mean + anything are `$currentObject` or the name of the variable that widget is bound + to (`datasource: $Customer` → `(Customer: $Customer)` is fine). Naming any other + variable is refused as **MDL-PAGEARG01** — it used to be accepted and silently + opened the page with the context object anyway. To open a page with something + else, call a microflow that shows it. + +**Button Styles:** `default`, `primary`, `success`, `info`, `warning`, `danger`, `inverse` +- Case-insensitive (`primary` and `Primary` both work). +- These are the only values Mendix recognizes — anything else (a typo, or `secondary`/`link`, which Mendix has no button style for) is rejected by `mxcli check` (MDL-WIDGET02). Previously an unknown value was silently rendered as `btn-default`. + +**Examples:** +```sql +-- Save with style +actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + +-- Navigate with parameter (inside DATAVIEW) +actionbutton btnEdit (caption: 'Edit', action: show_page Module.EditPage(Product: $Product)) + +-- Navigate with $currentObject (inside DATAGRID column) +actionbutton btnEdit (caption: 'Edit', action: show_page Module.EditPage(Product: $currentObject)) + +-- Call microflow with page/dataview parameter +actionbutton btnProcess (caption: 'Process', action: microflow Module.ACT_Process(Order: $Order), buttonstyle: success) + +-- Call microflow with $currentObject (inside DATAGRID/LISTVIEW column) +actionbutton btnDelete (caption: 'Delete', action: microflow Module.ACT_Delete(Target: $currentObject), buttonstyle: danger) + +-- Create object and show page +actionbutton btnNew (caption: 'New', action: create_object Module.Product then show_page Module.Product_Edit, buttonstyle: primary) +``` + +**Using `$currentObject`:** +Use `$currentObject` inside DATAGRID, LISTVIEW, or GALLERY columns to reference the current row's object. This is typically used in columns with `ShowContentAs: customContent` for action buttons. + +### LISTVIEW Specialization Templates + +A List View over a **generalization** can render a different body per +specialization. The template is identified by the entity it renders — it has no +name, which is why the keyword takes `for` and a qualified entity: + +```sql +listview vehicleListView (datasource: database from Pages.Vehicle) { + -- the default body: used for an object no template matches + dynamictext defaultVehicle (content: '{1} {2}', contentparams: [{1} = Brand, {2} = Model]) + + template for Pages.Bus { + dynamictext busLabel (content: 'Bus, capacity {1}', contentparams: [{1} = PassengerCapacity]) + } + template for Pages.Truck { + dynamictext truckLabel (content: 'Truck, max load {1} kg', contentparams: [{1} = MaxLoadKg]) + } +} +``` + +Rules: + +- The entity must be the list view's entity **or a specialization of it**. A + template for an unrelated entity can never match, so it is refused. +- **At most one template per entity.** +- Templates keep their **source order** — Mendix stores and matches in that + order, so it is authored, not derived, and DESCRIBE emits them as stored. +- Inside a template the context object **is the specialization**, so an + attribute only that specialization has resolves there (`PassengerCapacity` on + `Pages.Bus` above, which `Pages.Vehicle` does not have). + +Do not confuse this with a **Gallery's** `template `, which is a named +content slot, not a per-specialization body. + +### LINKBUTTON Widget + +Similar to ActionButton but rendered as link: + +```sql +linkbutton linkName (caption: 'Caption', action: ACTION_TYPE) +``` + +### LAYOUTGRID Widget + +Create responsive grid layout: + +```sql +layoutgrid gridName { + row rowName { + column colName (desktopwidth: 8) { + -- Nested widgets + } + column col2 (desktopwidth: 4) { + -- Nested widgets + } + } +} +``` + +**Column Width Properties:** + +| Property | Values | Default | Description | +|----------|--------|---------|-------------| +| `desktopwidth` | 1-12 or `autofill` | `autofill` | Desktop column width | +| `tabletwidth` | 1-12 or `autofill` | auto | Tablet column width | +| `phonewidth` | 1-12 or `autofill` | auto | Phone column width | + +```sql +column col1 (desktopwidth: 8, tabletwidth: 6, phonewidth: 12) { ... } +``` + +Example: +```sql +layoutgrid mainGrid { + row row1 { + column colMain (desktopwidth: 8) { + dynamictext heading (content: 'Main Content', rendermode: H3) + } + column colSide (desktopwidth: 4) { + dynamictext sideHeading (content: 'Sidebar', rendermode: H3) + } + } +} +``` + +### DATAGRID Widget + +Display list of objects using DataGrid widget: + +```sql +datagrid gridName ( + datasource: database from Module.Entity where [condition] sort by attributename asc|desc, + selection: Multi +) { + column colName (attribute: attributename, caption: 'Label') +} +``` + +> **Reserved keyword column names:** If the attribute name is an MDL reserved keyword (e.g. `Status`, `Type`), you must quote the attribute value and use a distinct widget name for the column: +> ```sql +> column colStatus (attribute: "Status", caption: 'Status') +> column colType (attribute: "Type", caption: 'Type') +> ``` +> Writing `COLUMN Status (attribute: Status)` fails silently — `Status` and `Type` are parsed as keywords. Always use a `col`-prefixed widget name when the attribute name is reserved. + +**Column Properties:** + +| Property | Values | Default | Description | +|----------|--------|---------|-------------| +| `attribute` | attribute name | (required) | Attribute binding | +| `caption` | string | attribute name | Column header text | +| `Alignment` | `left`, `center`, `right` | `left` | Text alignment | +| `WrapText` | `true`, `false` | `false` | Wrap text in cell | +| `Sortable` | `true`, `false` | `true` (if attribute), `false` (if not) | Can sort column | +| `Resizable` | `true`, `false` | `true` | Can resize column | +| `Draggable` | `true`, `false` | `true` | Can reorder column | +| `Hidable` | `yes`, `hidden`, `no` | `yes` | Can hide column | +| `ColumnWidth` | `autofill`, `autoFit`, `manual` | `autofill` | Column width mode | +| `Size` | integer (px) | `1` | Width in pixels (when `ColumnWidth: manual`) | +| `visible` | expression string | `true` | Column-level visibility — hides/shows the whole column, so use page variables, NOT `$currentObject` (per-object widget visibility is different — see "Conditional Visibility and Editability") | +| `DynamicCellClass` | expression string | (empty) | Dynamic CSS class per cell | +| `tooltip` | text string | (empty) | Cell tooltip text | + +Only non-default column properties appear in `describe page` output. + +**Dynamic-text columns (`ShowContentAs: dynamicText`):** a column can render its cell as a formatted text template instead of a bare attribute — the same `Content` / `ContentParams` / `format (...)` syntax as a `dynamictext` widget (see below). The column needs no `Attribute`; give it a `Caption` for the header. + +```sql +datagrid gridName (datasource: database from Module.Entity) { + -- Decimal with 2 decimals + thousands separator: renders e.g. "Amt: -1,234.50" + column amount ( + Caption: 'Amount', + ShowContentAs: dynamicText, + Content: 'Amt: {1}', + ContentParams: [{1} = Amount format (decimalPrecision: 2, groupDigits: true)] + ) + column due (attribute: DueOn, caption: 'Due') +} +``` + +The `format (...)` block accepts `decimalPrecision`, `groupDigits`, `dateFormat` (`Date` / `DateTime` / `Time` / `Custom`), `customDateFormat`, and `enumFormat` (`Text` / `Image`). Formatting is applied by Mendix only to **attribute-bound** parameters — bind the bare attribute (`Amount`), not `toString(...)`. + +```sql +column colPrice ( + attribute: Price, caption: 'Unit Price', + Alignment: right, WrapText: true, + Sortable: false, Resizable: false, + Hidable: hidden, + ColumnWidth: manual, Size: 150, + DynamicCellClass: 'if($currentObject/Price > 100) then ''highlight'' else '''' ', + tooltip: 'Price in USD' +) +``` + +**Associated-attribute columns:** + +A column can bind an attribute *over an association*, not just an own-entity +attribute — use a bare association path `Assoc/Attr`: + +```sql +datagrid dgOrders (datasource: database from Sales.Order) { + column colNumber (attribute: Number, caption: 'Order #') + column colCustomer (attribute: Order_Customer/Name, caption: 'Customer') -- associated attr +} +``` + +The association name is bare (resolved against the grid's entity module); +multi-hop paths (`A/B/Attr`) are supported. Module-qualified associations +(`Module.Assoc/Attr`) are **not** accepted — use the bare name. + +**Custom Content Columns:** + +Columns can contain nested widgets instead of attribute bindings. These build +correctly on the default engine (mxbuild-verified, 0 errors) — an earlier CE0463 +(column property ordering) was fixed: + +```sql +column colActions (caption: 'Actions') { + actionbutton btnView (caption: 'View', action: close_page) +} +``` + +**Supported Datasource Types:** + +| Syntax | Description | +|--------|-------------| +| `datasource: database from Module.Entity` | Direct database query | +| `datasource: $Variable` | Variable bound (requires DATAVIEW parent with entity) | +| `datasource: microflow Module.GetData` | Microflow datasource — no `()`, the name alone | +| `datasource: nanoflow Module.GetData` | Nanoflow datasource (client-side, no server roundtrip) — no `()` | +| `datasource: selection widgetName` | Listen to selection from another widget | +| `datasource: association path` | Retrieve by association from context (ByAssociation) | +| `datasource: $currentObject/Module.Assoc` | Sugar for `association` — same semantics, reads more naturally | + +**With WHERE and SORT BY (inline in DataSource):** +```sql +datagrid dgActive ( + datasource: database from Module.Product where [IsActive = true] sort by Name asc +) { + column colName (attribute: Name, caption: 'Name') + column colPrice (attribute: Price, caption: 'Price') +} +``` + +**Complex WHERE conditions:** +```sql +datagrid dgFiltered ( + datasource: database from Module.Product + where [IsActive = true and contains(Code, 'a') and Price > 10] or [Stock < 2] + sort by Name asc, Price desc +) { + column colName (attribute: Name, caption: 'Name') +} +``` + +**Paging Properties:** + +| Property | Values | Default | Description | +|----------|--------|---------|-------------| +| `PageSize` | Any positive integer | 20 | Number of rows per page | +| `Pagination` | `buttons`, `virtualScrolling`, `loadMore` | `buttons` | Paging mode | +| `PagingPosition` | `bottom`, `top`, `both` | `bottom` | Position of paging controls | +| `ShowPagingButtons` | `always`, `auto` | `always` | When to show paging buttons | + +```sql +-- Paging buttons above and below, 25 rows per page +datagrid dgProducts ( + datasource: database Module.Product, + PageSize: 25, + PagingPosition: both +) { + column colName (attribute: Name, caption: 'Name') +} + +-- Virtual scrolling (no paging buttons) +datagrid dgLargeList ( + datasource: database Module.Product, + PageSize: 50, + Pagination: virtualScrolling +) { + column colName (attribute: Name, caption: 'Name') +} +``` + +Only non-default paging properties appear in `describe page` output. + +### DATAVIEW Widget + +Display single object with nested input widgets: + +```sql +dataview dvName (datasource: $VariableName) { + -- Nested input widgets + textbox txtName (label: 'Name', attribute: Name) + textarea txtDescription (label: 'Description', attribute: description) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } +} +``` + +### Input Widgets + +Input widgets must be inside a DATAVIEW context. Use `attribute:` to bind to attributes: + +**TEXTBOX** - Single-line text input: +```sql +textbox txtName (label: 'Label', attribute: attributename) +``` + +**TEXTAREA** - Multi-line text input: +```sql +textarea txtDescription (label: 'Description', attribute: description) +``` + +**CHECKBOX** - Boolean checkbox: +```sql +checkbox cbActive (label: 'Active', attribute: IsActive) +``` + +**RADIOBUTTONS** - Boolean or enum selection: +```sql +radiobuttons rbStatus (label: 'Status', attribute: status) +``` + +**DATEPICKER** - Date/time selection: +```sql +datepicker dpCreated (label: 'Created Date', attribute: CreatedDate) +``` + +**COMBOBOX** - Combo box (pluggable widget): +```sql +-- Enumeration mode (attribute is an enum type): +combobox cbCountry (label: 'Country', attribute: Country) + +-- Association mode: bind a reference. Requires the option DataSource (the target +-- entity whose objects fill the dropdown) AND a CaptionAttribute (display value). +-- The reference can be given as `Association:` or, equivalently, `attribute:`. +combobox cmbCustomer (label: 'Customer', Association: Order_Customer, datasource: database MyModule.Customer, CaptionAttribute: Name) +-- WRONG: `combobox (Association: X)` with no datasource — mxcli check errors +-- MDL-WIDGET16 (Mendix would otherwise drop the binding → CE0642). +``` + +### DataView with Form Layout + +```sql +dataview dataView1 (datasource: $Customer) { + textbox txtName (label: 'Name', attribute: Name) + textbox txtEmail (label: 'Email', attribute: Email) + textarea txtAddress (label: 'Address', attribute: Address) + combobox cbStatus (label: 'Status', attribute: status) + checkbox cbActive (label: 'Active', attribute: IsActive) + datepicker dpCreated (label: 'Created', attribute: CreateDate) + + footer footer1 { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } +} +``` + +**Form Orientation (label placement):** the DataView's Studio Pro "Form +Orientation" radio is stored as `LabelWidth` in BSON. Specify either form in MDL: + +```sql +dataview dv (datasource: $Customer, FormOrientation: Vertical) -- label above +dataview dv (datasource: $Customer, FormOrientation: Horizontal) -- label beside (default, LabelWidth=3) +dataview dv (datasource: $Customer, LabelWidth: 4) -- explicit, 0..12 columns of 12 +``` + +`LabelWidth: 0` ⇔ `FormOrientation: Vertical`. If both are given, `LabelWidth` wins. + +**Footer (`showFooter`):** a `footer { … }` block turns the footer on by itself, so +the property is only needed when the two would disagree: + +```sql +dataview dv (datasource: $Customer, showFooter: true) -- empty footer, shown +dataview dv (datasource: $Customer, showFooter: false) { -- widgets declared, hidden + footer f { dynamictext t (content: 'hidden') } +} +``` + +An explicit `showFooter` wins over the block in both directions, and hiding a footer +never discards its widgets. + +### GALLERY Widget + +Display items in card layout with selection and responsive columns: + +```sql +gallery galleryName ( + datasource: database from Module.Entity sort by Name asc, + selection: Single, + DesktopColumns: 3, + TabletColumns: 2, + PhoneColumns: 1 +) { + template template1 { + dynamictext name (content: '{1}', contentparams: [{1} = Name], rendermode: H4) + dynamictext email (content: '{1}', contentparams: [{1} = Email]) + } +} +``` + +**With Filter:** +```sql +gallery productGallery (datasource: database Module.Product, selection: single) { + filter filter1 { + textfilter searchName (attribute: Name) + } + template template1 { + dynamictext prodName (content: '{1}', contentparams: [{1} = Name], rendermode: H4) + dynamictext prodCode (content: 'SKU: {1}', contentparams: [{1} = Code]) + } +} +``` + +### Filter Widgets + +Filter widgets are used inside GALLERY FILTER containers to enable search/filtering: + +**TEXTFILTER** - Text search filter: +```sql +-- Simple binding to single attribute +textfilter searchName (attribute: Name) + +-- Multiple attributes with explicit list +textfilter textFilter1 (attributes: [Module.Entity.Name, Module.Entity.Code, Module.Entity.Description]) + +-- With filter type +textfilter textFilter1 (attributes: [Module.Entity.Description], filtertype: startsWith) +``` + +**FilterType Options:** +- `contains` (default) - Matches if attribute contains text +- `startsWith` - Matches if attribute starts with text +- `endsWith` - Matches if attribute ends with text +- `equal` - Exact match + +**NUMBERFILTER** - Numeric range filter: +```sql +numberfilter priceFilter (attributes: [Module.Entity.Price]) +``` + +**DATEFILTER** - Date range filter: +```sql +datefilter datefilter (attributes: [Module.Entity.CreateDate]) +``` + +**DROPDOWNFILTER** - Dropdown selection filter: +```sql +dropdownfilter statusFilter (attributes: [Module.Entity.Status]) +``` + +Filter by an **association** instead of an attribute — the options are the +associated objects. Giving the filter a `datasource:` (the OPTION list) selects +this mode; all three parts are required: + +```sql +column colCustomer (attribute: Order_Customer/Name, caption: 'Customer') { + dropdownfilter ddfCustomer ( + Association: Sales.Order_Customer, -- the reference on the GRID entity + datasource: database Sales.Customer, -- the option list (associated entity) + CaptionAttribute: Name -- what each option shows + ) +} +``` + +> **A column cannot bind the association itself.** `column c (attribute: Order_Customer)` +> is refused — Mendix has nowhere to store a reference in an attribute-typed widget +> property, and writing one anyway fails the build with CE1613 *"The selected attribute +> … no longer exists"*. To **show** a value from the associated object, traverse the +> reference (`attribute: Order_Customer/Name`); to **filter** by it, use the mode above. + +### NAVIGATIONLIST Widget + +Create a menu with action items: + +```sql +navigationlist navName { + item itemEdit (caption: 'Edit', action: show_page Module.EditPage(entity: $EntityParameter)) + item itemDelete (caption: 'Delete', action: delete) + item itemBack (caption: 'Back', action: close_page) +} +``` + +### SNIPPETCALL Widget + +Embed a reusable snippet: + +```sql +-- Simple snippet call +snippetcall snippetName (snippet: Module.SnippetName) + +-- With parameters +snippetcall actions (snippet: Module.EntityActions, params: {entity: $Param}) +``` + +**A parameter satisfied by the enclosing data context takes NO mapping.** Mendix +has no variable meaning "the surrounding context" — the correct model is the +*absence* of a mapping, which is what Studio Pro writes. So inside a DataView +bound to the parameter's entity, just omit `Params:`: + +```sql +dataview dvOrder (datasource: $Order) { + snippetcall scActions (snippet: MyModule.OrderActions) -- parameter comes from dvOrder +} +``` + +`params: {Order: $currentObject}` means the same thing and produces the same +(empty) mapping. Naming a real page parameter or variable produces a real +mapping, as expected: + +```sql +snippetcall scActions (snippet: MyModule.OrderActions, params: {Order: $Order}) +``` + +Omitting `Params:` where the context is a *different* entity is still an error — +nothing there can satisfy the parameter. + +### IMAGE / STATICIMAGE / DYNAMICIMAGE Widgets + +Display images on pages: + +```sql +-- Image with dimensions (responsive by default) +image imgLogo (width: 200, height: 100) +staticimage imgBanner (width: 400, height: 120) + +-- Dynamic image (from entity data source, e.g. inside a DataView) +dynamicimage imgProduct (width: 300, height: 200) + +-- Image without explicit dimensions +image imgIcon +``` + +**Properties:** `width: integer`, `height: integer`, `AlternativeText: 'text'`, `WidthUnit: pixels | percentage | auto`, `HeightUnit: pixels | percentage | auto`, `Responsive: true | false`, `DisplayAs: fullImage | thumbnail | icon`, `class: 'css'`, `style: 'css'` + +#### Setting Image Source (PLUGGABLEWIDGET syntax) + +The IMAGE shorthand creates a pluggable Image widget. For advanced properties like image source, use PLUGGABLEWIDGET syntax: + +| Mode | Property | Use Case | +|------|----------|----------| +| `datasource: image` | `imageObject` | Dynamic image from entity (default) | +| `datasource: imageUrl` | `imageUrl: 'path'` | Static image from URL or file path | +| `datasource: icon` | `imageIcon` | Icon-based image | + +```sql +-- Static image from file (logos, branding) +pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( + datasource: imageUrl, + imageUrl: 'img/logo.svg', + widthUnit: pixels, width: 48, + heightUnit: pixels, height: 48 +) + +-- Update existing IMAGE via ALTER PAGE +alter page Mod.Home { + replace imgLogo with { + pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo ( + datasource: imageUrl, imageUrl: 'img/logo_dark.svg', + widthUnit: pixels, width: 48, heightUnit: pixels, height: 48 + ) + } +}; +``` + +For theme images, use paths relative to `theme/web/` (e.g., `img/logo.svg` → `theme/web/img/logo.svg`). + +**A per-row image URL comes from the entity, two ways.** `imageUrl` is a text +template, so it takes either spelling: + +```sql +-- named placeholder: shortest form for a single attribute +pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( + datasource: imageUrl, imageUrl: '{PictureUrl}' +) + +-- numbered placeholders + contentparams: needed for several values, or a format block +pluggablewidget 'com.mendix.widget.web.image.Image' cardImage ( + datasource: imageUrl, + imageUrl: '{1}/{2}', contentparams: [{1} = BaseUrl, {2} = PictureUrl] +) +``` + +Every `{N}` must have a matching parameter — Mendix rejects a shortfall with +`CE0720` ("place holder index N is greater than …, the number of parameter(s)"). +Parameters with no `{N}` to fill are reported by MDL-WIDGET21 rather than +dropped in silence. + +### Buttons Have Visibility, Not Editability + +`editable:` only exists on **input** widgets — Mendix gives exactly eleven page +widgets an editability setting (textbox, textarea, checkbox, datepicker, +dropdown, radiobuttons, referenceselector, inputreferencesetselector, +filemanager, imageuploader, and dataview). No button of any kind has one, so +`editable:` on a button is reported by MDL-WIDGET20 and does nothing. + +To disable a button conditionally, hide it instead — buttons do support +conditional visibility — or put the condition in the microflow it calls: + +```sql +actionbutton btnSubmit ( + caption: 'Submit', action: microflow Mod.ACT_Submit, + visible: [$currentObject/Status = Mod.Status.Draft] +) +``` + +### CONTAINER / CUSTOMCONTAINER Widgets + +Generic container for grouping widgets. `customcontainer` is an alias for `container` (both map to `Forms$DivContainer`): + +```sql +-- Basic container with CSS class +container card1 (class: 'card', style: 'padding: 16px;') { + dynamictext title (content: 'Card Title', rendermode: H4) + dynamictext body (content: 'Card body content') +} + +-- Container with design properties +container spaced1 (designproperties: ['Spacing top': 'Large', 'Full width': on]) { + dynamictext text1 (content: 'Spaced full-width content') +} + +-- Nested containers with combined styling +customcontainer outer1 (class: 'section') { + container inner1 (class: 'card', designproperties: ['Spacing top': 'Medium']) { + dynamictext text1 (content: 'Nested content') + } +} +``` + +**Clickable container (On click action).** A container can trigger an action when +clicked — use `OnClick:` (or the equivalent `Action:` keyword) with any client +action (`microflow`, `nanoflow`, `show_page`, `save_changes`, …): + +```sql +container card1 (OnClick: microflow MyModule.ACT_OpenDetails, class: 'clickable-card') { + dynamictext title (content: 'Open details') +} +``` + +This maps to the Mendix Container's **On click** event (`Forms$DivContainer.OnClickAction`). +A container with no `OnClick:`/`Action:` is non-clickable (a no-op action), exactly as in Studio Pro. + +**Prefer a clickable container over an `actionbutton` when the trigger needs child +widgets or a context argument.** An `actionbutton` can't contain child widgets and +maps only page-context object parameters — so a tile showing a digit over a label, +or a card that opens a specific object, is best built as a `container` with +`OnClick:`. The container's `OnClick:` takes the same `(Param: …)` argument syntax +as an `actionbutton`'s `action:`: + +```sql +-- Rich, parameterised trigger: a card that opens the object it represents +container tileCard (OnClick: microflow MyModule.ACT_Open(Item: $currentObject), class: 'tile') { + dynamictext tileValue (content: '4') + dynamictext tileLabel (content: '4 LEFT', class: 'tile-label') +} +``` + +Note the Mendix limit this works around: a button (or `OnClick`) can map **object** +parameters from the page context but **cannot pass a literal argument**. To vary a +literal per trigger (e.g. a 1–9 number pad), make one real microflow and a thin +wrapper per value (`ACT_Set1`…`ACT_Set9`), each calling the shared implementation. + +### FOOTER Widget + +Container for form action buttons: + +```sql +footer footerName { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) +} +``` + +### HEADER Widget + +Container for header content: + +```sql +header headerName { + dynamictext title (content: 'Form Title', rendermode: H3) +} +``` + +### CONTROLBAR Widget + +Control bar for data widgets: + +```sql +controlbar controlBar1 { + actionbutton btnNew (caption: 'New', action: create_object Module.Entity then show_page Module.EditPage, buttonstyle: primary) +} +``` + +### Charts (Charts.mpk — ColumnChart / BarChart / AreaChart / PieChart) + +Charts are pluggable widgets whose data lives in one or more `series` object-list +items. Each series binds a datasource (an OQL **view entity** is the natural feed — +one row per category) and picks X/Y attributes on that datasource. Requires the +Charts widget installed (`widgets/Charts.mpk`); run `mxcli widget init -p app.mpr`. + +```sql +-- Aggregated view entity = the chart's data source +create view entity Sales.ByRegion (Region: string(100), Total: decimal) as + select s.Region as Region, sum(s.Amount) as Total + from Sales.Sale as s group by s.Region; + +create page Sales.Dashboard (Title: 'Revenue', Layout: Atlas_Core.Atlas_Default) { + pluggablewidget 'com.mendix.widget.web.columnchart.ColumnChart' revenueChart { + series sRevenue ( + dataSet: static, + DataSource: database from Sales.ByRegion, -- or: staticDataSource: database from Sales.ByRegion + StaticXAttribute: Region, + StaticYAttribute: Total, + StaticName: 'Revenue' + ) + } +} +``` + +Notes: +- `DataSource:` inside a series is a friendly alias for `staticDataSource:` / + `dynamicDataSource:` (chosen by `dataSet`); either form works. +- X/Y attributes resolve against the **series' own** datasource entity, not the page. +- Add multiple `series ( ... )` blocks for multi-series charts. BarChart/AreaChart/ + PieChart use the same `series` shape. +- **CE0463 at `mx check`**: charts can report "widget definition changed" from + widget-version drift (embedded template vs the installed `Charts.mpk`), even for a + chart with no series. Clear it with **`mxcli docker check`/`build`**, which normalize + the widgets and preserve your storage format. + **Do NOT run bare `mx update-widgets` on an MPRv2 project** (one with an + `mprcontents/` folder — everything `mxcli new` creates). `mx update-widgets` rewrites + the project into the single-file v1 format and **deletes `mprcontents/`**, which + corrupts a git working tree, breaks a running `mxcli run --local` loop, and can leave + Studio Pro unable to open the project. `mxcli docker check`/`build` snapshot and + restore the v2 files around the normalization, so they are safe; raw + `mx update-widgets` is only safe on a v1 project (or on a throwaway copy used purely + for diagnosis). +- LineChart/BubbleChart/HeatMap/TimeSeries are **also MDL-authorable** (via the + `line`/`scalecolor` object-lists) — see + `mdl-examples/doctype-tests/34-chart-widget-examples.mdl` for working examples. + (They author on the modelsdk engine.) +- **Slider / RangeSlider**: set `showTooltip: false`. The tooltip calls React's + removed `findDOMNode` on Mendix 11, throwing "Could not render widget" on drag — a + runtime crash `mx check` cannot catch (only a running build does). See + `atlas-design` for the full runtime-verification rule. diff --git a/.claude/skills/mendix/generate-domain-model/SKILL.md b/.claude/skills/mendix/generate-domain-model/SKILL.md index babbd780d..4644a92b4 100644 --- a/.claude/skills/mendix/generate-domain-model/SKILL.md +++ b/.claude/skills/mendix/generate-domain-model/SKILL.md @@ -7,6 +7,19 @@ description: "Generate a complete Mendix domain model in MDL — entities, attri Use this skill to generate Mendix domain model scripts in MDL (Mendix Definition Language) format and validate them with the linter. +## Reference files + +`SKILL.md` covers the process and the decisions — what to model, in what order, +and how to check it. The lookup material is next door: + +- [`reference/syntax.md`](reference/syntax.md) — the complete syntax for entities, + attributes, associations and enumerations, every attribute type, plus reserved + keywords and entity positioning. **Check a spelling here rather than guessing**; + a reserved word used as an attribute name fails in Studio Pro, not in the parser. +- [`reference/patterns.md`](reference/patterns.md) — the recurring domain shapes + (header/detail, categorisation, audit, soft delete, many-to-many with payload) + and a full worked e-commerce model. + ## When to Use This Skill - User asks to create a domain model for a specific use case @@ -14,621 +27,6 @@ Use this skill to generate Mendix domain model scripts in MDL (Mendix Definition - User requests a complete e-commerce, HR, CRM, or other business domain model - User needs validation of generated MDL scripts -## MDL Syntax Reference - -**CRITICAL: All CREATE statements MUST have JavaDoc-style documentation** - -Every CREATE statement (modules, entities, associations, enumerations, microflows) should have a /** ... */ comment explaining its purpose. This is essential for: -- Team collaboration and knowledge transfer -- Understanding domain model structure -- Long-term maintainability -- Auto-generated documentation - -### Module Creation - -```sql -/** - * Module for financial transaction management - * - * Handles accounts, transactions, budgets, and reporting. - * - * @since 1.0.0 - */ -create module Finance; -``` - -### Minimap Section Headers (MARK Comments) - -**IMPORTANT: Large MDL files (300+ lines) MUST use MARK comments for navigation** - -Use `-- MARK: Section Name` comments to create collapsible sections in code editors. This dramatically improves navigation and organization in large domain model files. - -**Format**: `-- MARK: Section Name` - -**Required for files:** -- 300+ lines: At least 3 MARK comments -- 500+ lines: At least 5 MARK comments - -**Recommended sections:** -```sql --- MARK: ENUMERATIONS - --- MARK: CORE ENTITIES - --- MARK: ASSOCIATIONS - --- MARK: VIEW ENTITIES - --- MARK: MICROFLOWS -``` - -**With subsections:** -```sql --- MARK: - Core Entities (Persistent) - --- MARK: - View Entities for Reporting -``` - -**Benefits:** -- Creates outline/minimap view in VS Code, Xcode-style editors -- Makes large files navigable with jump-to-section -- Groups related code logically -- Improves team collaboration on complex models - -### Enumerations - -```sql -/** - * Transaction type classification - * - * Categorizes financial transactions as income or expense - * for proper accounting and reporting. - * - * @since 1.0.0 - */ -create enumeration Module.TransactionType ( - INCOME 'Income', - EXPENSE 'Expense' -); -``` - -**Editing an existing enumeration** — use `alter enumeration`, never drop + recreate -(a drop is blocked while the enum is referenced by an attribute): - -```sql -alter enumeration Module.TransactionType add value REFUND caption 'Refund'; -alter enumeration Module.TransactionType rename value INCOME to CREDIT; -- changes the name/key -alter enumeration Module.TransactionType modify value EXPENSE caption 'Expense / Debit'; -- caption only, name unchanged -alter enumeration Module.TransactionType drop value REFUND; -``` - -`modify value … caption` re-captions in place — the value keeps its identity, so it -works even while the enumeration is in use. (Value names in `alter` must be plain -identifiers; a value whose name is a reserved word can't be targeted by `alter`.) - -### Entities - -**IMPORTANT: All entities MUST have @Position annotation** - -The `@position(x, y)` annotation specifies where the entity appears in the domain model diagram. Without it, entities appear at (0,0) or random locations. - -**Position Guidelines:** -- Use increments of 50 or 100 for spacing (e.g., 100, 200, 300) -- Leave space between entities (at least 200 pixels) -- Organize related entities in logical groups -- Example layout: Categories at y=100, Transactions at y=300, Reports at y=500 - -**Association line anchors** — where the connector attaches to each entity box — -are set with `@anchor`, as a **percentage of the box** (0..100, whole numbers): - -```sql -@anchor(from: (0, 54), to: (100, 54)) -create association Sales.Order_Customer - from Sales.Order to Sales.Customer; -``` - -`from` is the anchor on the FROM entity's box, `to` the anchor on the TO -entity's. `(0, 50)` is the middle of the left edge, `(100, 50)` the middle of the -right, `(50, 100)` the bottom centre. - -Retune a line without restating the association: - -```sql -alter association Sales.Order_Customer set anchor from (50, 100) to (50, 0); -``` - -**Naming an end sets it; not naming one preserves what is stored.** An -association written without `@anchor` keeps whatever the line was dragged to in -Studio Pro, so a `create or modify association` about the delete behaviour never -flattens someone's layout. `describe association` re-emits a non-default pair as -the same `@anchor(...)` annotation, so describe → edit → exec round-trips. - -Cross-module associations have no anchors at all — Mendix stores none, and -`set anchor` on one is refused. - -#### Persistent Entity - -```sql -/** - * Entity description - * - * Detailed explanation of what this entity represents. - * - * @since 1.0.0 - * @see Module.RelatedEntity - */ -@position(100, 100) -create persistent entity Module.EntityName ( - /** Unique identifier */ - Id: long not null error 'ID is required' unique error 'ID must be unique', - /** Attribute description */ - attributename: string(200) not null error 'Attribute name is required', - /** Numeric value */ - Amount: decimal, - /** Date field */ - CreationDate: date, - /** Boolean flag */ - IsActive: boolean not null error 'IsActive flag is required' default true, - /** Enumeration field */ - status: enumeration(Module.StatusEnum) not null error 'Status is required' -); -``` - -#### Entity Indexes (Performance Optimization) - -**CRITICAL: INDEX syntax goes AFTER the closing parenthesis, with NO comma before** - -Indexes improve query performance for frequently filtered or sorted columns. Add them to persistent entities when: -- Column is used in WHERE clauses frequently -- Column is used for sorting (ORDER BY) -- Composite indexes for multi-column filters - -**Syntax:** -```sql -create persistent entity Module.Transaction ( - TransactionDate: datetime not null, - status: enumeration(Module.Status) not null, - Amount: decimal not null, - IsRecurring: boolean default false -) -index (TransactionDate desc) -index (status, TransactionDate) -index (IsRecurring); -``` - -**Index Guidelines:** -- **Position**: AFTER closing parenthesis, NO comma before first INDEX -- **No names**: Unlike SQL CREATE INDEX, MDL indexes don't have names -- **Sort direction**: ASC or DESC are optional (default is ASC) -- **Composite indexes**: Order matters - put most selective columns first -- **Limit**: Don't over-index - each index has storage/write overhead - -**Common index patterns:** -- Date fields: `index (CreatedDate desc)` - for recent-first queries -- Status filters: `index (status, CreatedDate desc)` - for filtered date ranges -- Boolean flags: `index (IsActive)` - for active/inactive filtering -- Foreign keys: Automatically indexed by associations - -#### Entity Generalization (EXTENDS) - -**CRITICAL: EXTENDS goes BEFORE the opening parenthesis, not after!** - -Use `extends` to inherit from a parent entity. Common for file/image storage using System entities. - -```sql --- Correct: EXTENDS before ( -create persistent entity Module.ProductPhoto extends System.Image ( - PhotoCaption: string(200), - SortOrder: integer default 0 -); - --- Correct: File document specialization -create persistent entity Module.Attachment extends System.FileDocument ( - AttachmentDescription: string(500) -); - --- Correct: Custom entity inheritance -create persistent entity Module.Employee extends Module.Person ( - EmployeeNumber: string(20) -); -``` - -**Wrong** (parse error): -```sql --- EXTENDS after ) = parse error! -create persistent entity Module.Photo ( - PhotoCaption: string(200) -) extends System.Image; -``` - -**Note:** `mxcli syntax entity` output may show EXTENDS after `)` — this is misleading. Always place EXTENDS before `(`. - -**Security follows inheritance.** Mendix inheritance is multi-table: all of the -parent's attributes are members of the child, so a specialized entity's access rule -must cover them. Grant an inherited member exactly like one of the entity's own — -`grant Module.Viewer on Module.Attachment (read (AttachmentDescription, "Name", Size));` -— and `read *` / `write *` cover them too. Skipping them is Mendix CE0066 "Entity -access is out of date". The one exception is entities extending `System.User`, whose -inherited platform members Mendix manages and which must not be granted. See -`manage-security`. - -#### System Attributes (Auditing) - -Mendix supports four built-in auditing properties on persistent entities. Declare them as regular attributes using pseudo-types (like `autonumber`): - -| Pseudo-Type | System Attribute | Set When | -|-------------|-----------------|----------| -| `autoowner` | `System.owner` (→ System.User) | Object created | -| `autochangedby` | `System.changedBy` (→ System.User) | Every commit | -| `autocreateddate` | `CreatedDate` (DateTime) | Object created | -| `autochangeddate` | `ChangedDate` (DateTime) | Every commit | - -```sql -/** - * Order with full audit trail - */ -create persistent entity Sales.Order ( - OrderNumber: autonumber default 1, - TotalAmount: decimal not null, - status: enumeration(Sales.OrderStatus) not null, - owner: autoowner, - ChangedBy: autochangedby, - CreatedDate: autocreateddate, - ChangedDate: autochangeddate -); -``` - -To enable/disable on existing entities, use ALTER ENTITY ADD/DROP ATTRIBUTE: - -```sql -alter entity Sales.Order add attribute owner: autoowner; -alter entity Sales.Order add attribute ChangedDate: autochangeddate; -alter entity Sales.Order drop attribute ChangedBy; -``` - -**When to use auditing:** -- Compliance/regulated domains (finance, healthcare) — use all four -- User-generated content — use AutoOwner for ownership-based access rules -- "Recently modified" lists — use AutoChangedDate -- Avoid on high-volume system tables (every write touches the audit columns) - -#### Non-Persistent Entity - -**IMPORTANT: Non-persistent entities cannot have validation rules** (`not null error`, `unique error`) on attributes. They can only have `default` values. - -```sql -/** - * Non-persistent entity description - * - * @since 1.0.0 - */ -@position(200, 100) -create non-persistent entity Module.TemporaryData ( - SessionId: string(100), - data: string(1000), - IsActive: boolean default false -); -``` - -#### View Entity (with OQL) - -```sql -/** - * View entity description - * - * @since 1.0.0 - */ -@position(300, 500) -create view entity Module.ViewName ( - Attribute1: type, - Attribute2: type -) as ( - select - e.Id as Id, - e.Name as Name, - e.Amount as Amount - from Module.Entity as e - where e.IsActive = true -); -``` - -**Enumeration Comparisons in OQL:** - -When comparing enumeration attributes in OQL WHERE clauses, use the **enumeration value** (identifier), not the caption: - -```sql --- Enumeration definition -create enumeration Module.OrderStatus ( - PENDING 'Pending', - PROCESSING 'Processing', - CANCELLED 'Cancelled' -); - --- OQL comparison - use the VALUE, not the caption -where e.Status != 'CANCELLED' -- Correct: uses enum value -where e.Status != 'Cancelled' -- Wrong: this is the caption -``` - -### Entity Event Handlers - -Microflows can run before/after entity Create, Commit, Delete, or Rollback. Use the optional `raise error` clause to make a handler act as a validation microflow — if it returns false, the operation is aborted. - -```sql --- In CREATE ENTITY (handlers go after attributes/indexes) -create persistent entity Sales.Order ( - Total: decimal, - status: string(50) -) -on before commit call Sales.ACT_ValidateOrder raise error -on after create call Sales.ACT_InitDefaults; - --- Add via ALTER ENTITY -alter entity Sales.Order - add event handler on before delete call Sales.ACT_CheckCanDelete raise error; - --- Drop via ALTER ENTITY -alter entity Sales.Order - drop event handler on before commit; -``` - -**Moments**: `before`, `after` -**Events**: `create`, `commit`, `delete`, `rollback` - -Each (Moment, Event) combination can only have one handler per entity. The microflow must exist (the executor validates the reference). `raise error` is optional — without it, the handler runs but its return value doesn't affect the operation. - -### Associations - -**CRITICAL: Association Directionality** - -In Mendix, associations are defined **FROM the entity that contains the foreign key TO the entity that is referenced**. - -Think of it like this: -- A `Transaction` knows which `Account` it belongs to → Transaction contains the foreign key -- Therefore: `from Transaction to Account` -- **NOT** `from Account to Transaction` ❌ - -**Common Patterns**: - -```sql --- ❌ INCORRECT: Account doesn't store transaction references -create association Finance.Account_Transaction -from Finance.Account to Finance.Transaction -type reference; - --- ✅ CORRECT: Transaction stores the account reference (foreign key) -create association Finance.Transaction_Account -from Finance.Transaction to Finance.Account -type reference; - --- ✅ One-to-Many: Customer has many Orders (each order knows its customer) -create association Sales.Order_Customer -from Sales.Order to Sales.Customer -type reference; - --- ✅ Many-to-Many: Use ReferenceSet and choose which side stores the relationship -create association Sales.Order_Products -from Sales.Order to Sales.Product -type ReferenceSet -owner both; -``` - -**Full Association Syntax**: - -```sql -/** - * Association description - * - * Explain the relationship and directionality. - * - * @since 1.0.0 - */ -create association Module.EntityWithFK_ReferencedEntity -from Module.EntityWithFK to Module.ReferencedEntity -type reference -owner default -delete_behavior DELETE_BUT_KEEP_REFERENCES -comment 'Additional documentation'; -``` - -**Idempotency**: plain `create association` is **not** idempotent — re-running it -errors with `association already exists`, which aborts the rest of the script (and -any associations defined *after* it are never created). Write **`create or modify -association`** from the first draft — same clauses, but re-running is a no-op: - -```sql -create or modify association Module.Child_Parent -from Module.Child to Module.Parent -type reference; -``` - -**Association Types**: -- `reference` - One-to-one or many-to-one (foreign key on FROM entity) -- `ReferenceSet` - One-to-many or many-to-many (collection) - -**Owner Options**: -- `default` - Standard ownership (FROM entity owns the reference) -- `both` - Both sides own the association (bidirectional) -- `Parent` - Only parent (TO) entity owns -- `Child` - Only child (FROM) entity owns - -> **Use `default` ownership for a normal to-one reference.** Reserve `owner both` -> for a `ReferenceSet` (many-to-many). On a plain `type reference`, `owner both` -> makes the association navigable **to-one from *both* sides** — so the reverse -> direction is a single object, not a collection. A **list** widget (listview/ -> datagrid/gallery) over the reverse then fails MxBuild **CE8812** "A grid -> association path must result in a list." (a DataView over the reverse is fine — -> it's a single object). With `default` ownership, the reverse is the expected -> to-many collection and a list widget works. See master-detail-pages for the -> widget patterns. - -**Delete Behaviors**: -- `DELETE_AND_REFERENCES` - Delete object and all referencing objects -- `DELETE_BUT_KEEP_REFERENCES` - Delete object, keep references (nullify) -- `DELETE_IF_NO_REFERENCES` - Only delete if no objects reference it -- `cascade` - Cascade delete to associated objects -- `prevent` - Prevent deletion if references exist - -**Naming Convention**: `{FromEntity}_{ToEntity}` (e.g., `Order_Customer`, `Transaction_Account`) - -#### Calculated Attributes - -Calculated attributes derive their value from a microflow at runtime. Use `calculated by Module.Microflow` to specify the calculation microflow. - -**IMPORTANT: CALCULATED attributes are only supported on PERSISTENT entities.** Using CALCULATED on non-persistent entities will produce a validation error. - -```sql -@position(100, 100) -create persistent entity Module.OrderLine ( - /** Unit price */ - UnitPrice: decimal not null, - /** Quantity ordered */ - Quantity: integer not null, - /** Total price, calculated by microflow */ - TotalPrice: decimal calculated by Module.CalcTotalPrice -); -``` - -**Syntax variants:** -- `calculated by Module.Microflow` — recommended, binds the calculation microflow directly -- `calculated Module.Microflow` — also valid (`by` keyword is optional) -- `calculated` — bare form, marks as calculated but requires manual microflow binding in Studio Pro - -**The microflow's signature is checked, and mxcli refuses a mismatch before -writing** — Mendix reports these as **CE7247** at build time (verified on 11.13.0): - -| Microflow | Result | -|-----------|--------| -| takes the owning entity (`$Order: Module.Order`) | ✅ stored with `PassEntity = true` | -| takes **no** parameter | ✅ stored with `PassEntity = false` — equally valid | -| takes a *different* entity | ❌ refused: CE7247 *"Microflow parameter 'X' should be of type Module.Order."* | -| takes two or more parameters | ❌ refused | -| returns the wrong type | ❌ refused: CE7247 *"Microflow return type should be …"* | -| returns `Long` for an `integer` attribute (or vice versa) | ✅ accepted — Integer and Long are one family here | - -A microflow **created earlier in the same script** cannot be inspected yet, so -its signature is not checked; the build has the last word on those. - -> **Before mxcli 0.17 the binding was silently discarded** on the default -> engine: the attribute was written as an ordinary stored value, `mx check` -> reported 0 errors, and the attribute stayed empty at runtime (#917). If you -> have attributes that were declared `calculated by` and never calculated, they -> need re-running through a current mxcli — re-executing the same statement is -> enough. - -### Data Types - -| Type | Example | Description | -|------|---------|-------------| -| `string(length)` | `string(200)` | Text field with max length | -| `integer` | `integer` | 32-bit integer | -| `long` | `long` | 64-bit integer (use for IDs) | -| `decimal` | `decimal` | Decimal number | -| `boolean` | `boolean` | True/false | -| `datetime` | `datetime` | Date and time | -| `date` | `date` | Date only | -| `binary` | `binary` | Binary data | -| `autonumber` | `autonumber default 1` | Auto-incrementing number (requires DEFAULT start value) | -| `enumeration(Module.Enum)` | `enumeration(Shop.Status)` | Enumeration reference | - -### Constraints - -**Basic Constraints:** -- `not null` - Field is required -- `unique` - Value must be unique -- `default value` - Default value - -**Validation Error Messages:** - -Each constraint can have a custom error message using `error 'message'` syntax: - -```sql -create persistent entity Module.Customer ( - /** Customer name - required with custom error */ - Name: string(200) not null error 'Name is required', - /** Email - required and unique with separate error messages */ - Email: string(200) not null error 'Email is required' unique error 'Email must be unique', - /** Age with default value */ - Age: integer default 0, - /** Active status flag */ - IsActive: boolean not null error 'IsActive flag is required' default true -); -``` - -**Error Message Guidelines:** -- Place `error 'message'` immediately after the constraint -- Multiple constraints can each have their own error message -- Keep messages clear and user-friendly -- Follow the pattern: `not null error 'X is required'` for required fields -- For UNIQUE: `unique error 'X must be unique'` -- Error messages are shown to end users during validation - -**Common patterns:** -```sql --- Required field -Name: string(200) not null error 'Name is required', - --- Required and unique -Email: string(200) not null error 'Email is required' unique error 'Email must be unique', - --- Required with default -IsActive: boolean not null error 'IsActive flag is required' default true, - --- Enum with required error -status: enumeration(Module.Status) not null error 'Status is required', - --- Enum with default value (use fully qualified Module.Enum.Value) -Priority: enumeration(Module.Priority) default Module.Priority.Normal -``` - -## Reserved Keywords - -**Best practice: Always quote all identifiers** (entity names, attribute names) with double quotes. This escapes every **MDL parser** keyword conflict — quotes are stripped automatically by the parser. So `"create"`, `"status"`, `"end"` become valid attribute names. - -```sql -create persistent entity Module."VATRate" ( - "create": datetime, - "Rate": decimal, - "status": string(50) -); -``` - -> **Caveat — quoting does not exempt *platform*-reserved member names.** Some names are -> reserved by the Mendix *platform*, not just the MDL parser, and are rejected **even when -> quoted** (the check strips the quotes and still flags them): `Type` (CE7247, MDL021), -> the audit attributes `CreatedDate` / `ChangedDate` / `Owner` / `ChangedBy` (MDL020 — use -> the `AutoCreatedDate` / `AutoChangedDate` / `AutoOwner` / `AutoChangedBy` pseudo-types -> instead), plus `ID`, `GUID`, `CurrentUser` and the Java-keyword list. `"Type": String` -> fails MDL021 — rename to `ResourceType` / `TypeValue`. - -Both `"Name"` and `` `Name` `` syntax are supported. Prefer double quotes for consistency. - -**Boolean attributes** auto-default to `false` when no `default` is specified: -```sql -create persistent entity Module.Item ( - IsActive: boolean, -- auto-defaults to false - IsPublished: boolean default true -); -``` - -## Entity Positioning - -Use `@position(x, y)` to control layout in Studio Pro: -- Place related entities near each other -- Use consistent spacing (e.g., 250 pixels horizontal, 200 vertical) -- Group by domain concept - -Example layout: -```sql -@position(50, 50) -- Top-left: Core entity -create persistent entity Module.Customer (...); - -@position(300, 50) -- Same row: Related entity -create persistent entity Module.Address (...); - -@position(50, 250) -- Below: Dependent entity -create persistent entity Module.Order (...); -``` - ## Modelling for the client: widgets bind members, not expressions A page widget binds an **attribute or association path** — never an expression. There @@ -779,116 +177,6 @@ Ensure: - ✅ No duplicate names - ✅ Valid OQL queries -## Example: E-Commerce Domain Model - -```sql --- ============================================================================ --- E-Commerce Domain Model --- ============================================================================ - -create module ECommerce; - --- Enumerations --- ============================================================================ - -/** - * Order status enumeration - * - * @since 1.0.0 - */ -create enumeration ECommerce.OrderStatus ( - Draft 'Draft', - Submitted 'Submitted', - Paid 'Paid', - Shipped 'Shipped', - Delivered 'Delivered', - Cancelled 'Cancelled' -); - --- Entities --- ============================================================================ - --- Customer Management --- ---------------------------------------------------------------------------- - -/** - * Customer entity - * - * Stores customer information for e-commerce platform. - * - * @since 1.0.0 - * @see ECommerce.SalesOrder - */ -@position(50, 50) -create persistent entity ECommerce.Customer ( - /** Unique customer identifier */ - CustomerId: long not null error 'Customer ID is required' unique error 'Customer ID must be unique', - /** Customer full name */ - FullName: string(200) not null error 'Full name is required', - /** Email address */ - Email: string(200) not null error 'Email is required' unique error 'Email must be unique', - /** Registration date */ - RegistrationDate: datetime not null error 'Registration date is required' -); - -/** - * Product entity - * - * Catalog of products available for purchase. - * - * @since 1.0.0 - */ -@position(50, 250) -create persistent entity ECommerce.Product ( - /** Unique product identifier */ - ProductId: long not null error 'Product ID is required' unique error 'Product ID must be unique', - /** Product name */ - ProductName: string(200) not null error 'Product name is required', - /** Product SKU */ - SKU: string(50) not null error 'SKU is required' unique error 'SKU must be unique', - /** Unit price */ - Price: decimal not null error 'Price is required', - /** Stock quantity */ - StockQuantity: integer not null error 'Stock quantity is required' -); - -/** - * Sales order entity - * - * Customer orders for products. - * - * @since 1.0.0 - */ -@position(300, 150) -create persistent entity ECommerce.SalesOrder ( - /** Unique order identifier */ - OrderId: long not null error 'Order ID is required' unique error 'Order ID must be unique', - /** Order number */ - OrderNumber: string(50) not null error 'Order number is required' unique error 'Order number must be unique', - /** Order date */ - OrderDate: datetime not null error 'Order date is required', - /** Total amount */ - TotalAmount: decimal not null error 'Total amount is required', - /** Order status */ - status: enumeration(ECommerce.OrderStatus) not null error 'Status is required' -); - --- Associations --- ============================================================================ - -/** - * Customer orders - * - * Links customers to their orders. - * - * @since 1.0.0 - */ -create association ECommerce.Customer_Orders -from ECommerce.Customer to ECommerce.SalesOrder -type ReferenceSet -owner both; -``` - ## Testing the Script 1. **Save to file**: Save as `examples/my-domain-model.mdl` @@ -906,205 +194,6 @@ owner both; 4. **Check Studio Pro**: Open project and verify entities appear correctly -## Common Patterns - -### One-to-Many Relationship -```sql --- Parent entity -create persistent entity Module.Parent (Id: long not null unique); - --- Child entity -create persistent entity Module.Child ( - Id: long not null unique, - ChildData: string(200) -); - --- Association (Parent has many Children) -create association Module.Parent_Children -from Module.Parent to Module.Child -type ReferenceSet -owner both; -``` - -### Many-to-Many Relationship -```sql --- Entity A -create persistent entity Module.EntityA (Id: long not null unique); - --- Entity B -create persistent entity Module.EntityB (Id: long not null unique); - --- Bidirectional association -create association Module.EntityA_EntityB -from Module.EntityA to Module.EntityB -type ReferenceSet -owner both; -``` - -### Hierarchical Relationship (Self-Reference) - -**IMPORTANT: Self-referencing associations must use `owner default`** (one-to-many). Using `owner both` is not supported for self-references. - -```sql -/** - * Category with parent-child hierarchy - */ -create persistent entity Module.Category ( - Id: long not null unique, - CategoryName: string(200) not null -); - -/** - * Parent category link (self-reference) - */ -create association Module.Category_ParentCategory -from Module.Category to Module.Category -type reference -owner default; -``` - -### ALTER ENTITY (Incremental Modifications) - -Use `alter entity` to make targeted changes to existing entities without redefining the entire entity: - -```sql --- Add a new attribute -alter entity Module.Customer - add attribute PhoneNumber: string(20); - --- Add multiple attributes at once -alter entity Module.Order - add attribute VATRate: decimal - add attribute VATAmount: decimal; - --- Rename an attribute (preserves data). Every stored reference follows it: --- microflow create/change members, page attribute widgets, validation rules, --- access rules -- and XPath constraints too ([CreatedDate > ...]), including --- ones that reach the entity through an association. Microflow expressions --- ($Order/CreatedDate) are NOT rewritten -- mxbuild reports those as CE0117, --- so build afterwards. -alter entity Module.Order - rename attribute CreatedDate to OrderDate; - --- Drop an attribute -alter entity Module.Product - drop attribute LegacyCode; - --- Modify attribute type -alter entity Module.Customer - modify attribute Address: string(500); - --- Modify attribute constraints. MODIFY applies the constraints you specify and --- preserves the ones you don't: --- NULLABLE -> make a required attribute optional (removes NOT NULL) --- NOT NULL -> make an optional attribute required --- UNIQUE -> add a uniqueness constraint --- DEFAULT x -> set/replace the default -alter entity Module.Customer - modify attribute Email: string(200) nullable; -- Email is now optional -alter entity Module.Customer - modify attribute Code: string(20) not null unique; - --- Set entity documentation -alter entity Module.Customer - set documentation 'Core customer entity for CRM module'; - --- Add an index -alter entity Module.Customer - add index idx_email (Email asc); - --- Reposition entity on domain model canvas -alter entity Module.Customer - set position (100, 200); -``` - -**Supported operations:** ADD ATTRIBUTE, RENAME ATTRIBUTE, MODIFY ATTRIBUTE (type + `NULLABLE`/`NOT NULL`/`UNIQUE`/`DEFAULT` constraints), DROP ATTRIBUTE, SET DOCUMENTATION, SET COMMENT, ADD INDEX, DROP INDEX, SET POSITION. - -> **`MODIFY ATTRIBUTE` always takes a type** — restate it even when you only want -> to change a constraint. Its type slot accepts a bare qualified name, so a -> clause written in the type position is read as a type name: -> `MODIFY ATTRIBUTE X SET DEFAULT 0` treats `SET` as the type. mxcli now refuses -> that; before it did, the statement rewrote the attribute to an enumeration and -> produced a project Mendix could not open (#910). -> -> To clear a default value use **`DROP DEFAULT ON ATTRIBUTE `**. - -### Entity Positioning Guidelines - -When creating or repositioning entities, follow these layout rules for readable domain models: - -- **Horizontal spacing:** 350px between columns (x = 50, 400, 750, 1100, ...) -- **Vertical spacing:** calculate per-column based on the entity above: `y = previous_y + 50 + (previous_entity_attribute_count * 20)` -- Entity header is ~40px, each attribute adds ~20px of height, plus ~50px padding -- **Position column-by-column**, not in rigid rows — avoids wasting space when entities have different attribute counts -- **Place related entities** in the same column or adjacent columns so associations are short - -Example layout for entities with varying attribute counts: - -``` -column 1 (x=50): column 2 (x=400): - entity A (4 attrs) entity C (14 attrs) - y=50 y=50 - - entity B (10 attrs) entity D (3 attrs) - y=180 (50+50+4*20) y=380 (50+50+14*20) -``` - -```sql --- Position entities after creation -alter entity Module.EntityA set position (50, 50); -alter entity Module.EntityB set position (50, 180); -alter entity Module.EntityC set position (400, 50); -alter entity Module.EntityD set position (400, 380); -``` - -### Entity Migration with CREATE OR MODIFY - -Use `create or modify` to update existing entities without losing data. The REPL computes differences and applies incremental changes. - -```sql -/** - * Customer entity migration - rename CustomerName to FullName - */ -create or modify persistent entity Module.Customer ( - /** Unique identifier (unchanged) */ - CustomerId: long not null unique, - - /** Renamed from CustomerName - data preserved */ - @RenamedFrom('CustomerName') - FullName: string(200) not null, - - /** New field */ - Email: string(255) unique, - - /** Type widened from String(100) to String(200) */ - Address: string(200) -); -``` - -**Key features:** -- `@RenamedFrom('oldName')` - renames attribute, preserves data -- Auto-removes attributes not in new definition -- Allows compatible type changes (e.g., String length increase) -- Preserves entity UUID (no data loss) - -### Status-Driven Entity -```sql --- Status enumeration -create enumeration Module.TaskStatus ( - Todo 'To Do', - InProgress 'In Progress', - Done 'Done' -); - --- Entity with status -create persistent entity Module.Task ( - Id: long not null unique, - TaskName: string(200) not null, - status: enumeration(Module.TaskStatus) not null -); -``` - ## Checklist Before finalizing an MDL script: diff --git a/.claude/skills/mendix/generate-domain-model/reference/patterns.md b/.claude/skills/mendix/generate-domain-model/reference/patterns.md new file mode 100644 index 000000000..2cb262a72 --- /dev/null +++ b/.claude/skills/mendix/generate-domain-model/reference/patterns.md @@ -0,0 +1,311 @@ +# Domain model patterns and a worked example + +Supporting reference for [generate-domain-model](../SKILL.md). + +## Example: E-Commerce Domain Model + +```sql +-- ============================================================================ +-- E-Commerce Domain Model +-- ============================================================================ + +create module ECommerce; + +-- Enumerations +-- ============================================================================ + +/** + * Order status enumeration + * + * @since 1.0.0 + */ +create enumeration ECommerce.OrderStatus ( + Draft 'Draft', + Submitted 'Submitted', + Paid 'Paid', + Shipped 'Shipped', + Delivered 'Delivered', + Cancelled 'Cancelled' +); + +-- Entities +-- ============================================================================ + +-- Customer Management +-- ---------------------------------------------------------------------------- + +/** + * Customer entity + * + * Stores customer information for e-commerce platform. + * + * @since 1.0.0 + * @see ECommerce.SalesOrder + */ +@position(50, 50) +create persistent entity ECommerce.Customer ( + /** Unique customer identifier */ + CustomerId: long not null error 'Customer ID is required' unique error 'Customer ID must be unique', + /** Customer full name */ + FullName: string(200) not null error 'Full name is required', + /** Email address */ + Email: string(200) not null error 'Email is required' unique error 'Email must be unique', + /** Registration date */ + RegistrationDate: datetime not null error 'Registration date is required' +); + +/** + * Product entity + * + * Catalog of products available for purchase. + * + * @since 1.0.0 + */ +@position(50, 250) +create persistent entity ECommerce.Product ( + /** Unique product identifier */ + ProductId: long not null error 'Product ID is required' unique error 'Product ID must be unique', + /** Product name */ + ProductName: string(200) not null error 'Product name is required', + /** Product SKU */ + SKU: string(50) not null error 'SKU is required' unique error 'SKU must be unique', + /** Unit price */ + Price: decimal not null error 'Price is required', + /** Stock quantity */ + StockQuantity: integer not null error 'Stock quantity is required' +); + +/** + * Sales order entity + * + * Customer orders for products. + * + * @since 1.0.0 + */ +@position(300, 150) +create persistent entity ECommerce.SalesOrder ( + /** Unique order identifier */ + OrderId: long not null error 'Order ID is required' unique error 'Order ID must be unique', + /** Order number */ + OrderNumber: string(50) not null error 'Order number is required' unique error 'Order number must be unique', + /** Order date */ + OrderDate: datetime not null error 'Order date is required', + /** Total amount */ + TotalAmount: decimal not null error 'Total amount is required', + /** Order status */ + status: enumeration(ECommerce.OrderStatus) not null error 'Status is required' +); + +-- Associations +-- ============================================================================ + +/** + * Customer orders + * + * Links customers to their orders. + * + * @since 1.0.0 + */ +create association ECommerce.Customer_Orders +from ECommerce.Customer to ECommerce.SalesOrder +type ReferenceSet +owner both; +``` +## Common Patterns + +### One-to-Many Relationship +```sql +-- Parent entity +create persistent entity Module.Parent (Id: long not null unique); + +-- Child entity +create persistent entity Module.Child ( + Id: long not null unique, + ChildData: string(200) +); + +-- Association (Parent has many Children) +create association Module.Parent_Children +from Module.Parent to Module.Child +type ReferenceSet +owner both; +``` + +### Many-to-Many Relationship +```sql +-- Entity A +create persistent entity Module.EntityA (Id: long not null unique); + +-- Entity B +create persistent entity Module.EntityB (Id: long not null unique); + +-- Bidirectional association +create association Module.EntityA_EntityB +from Module.EntityA to Module.EntityB +type ReferenceSet +owner both; +``` + +### Hierarchical Relationship (Self-Reference) + +**IMPORTANT: Self-referencing associations must use `owner default`** (one-to-many). Using `owner both` is not supported for self-references. + +```sql +/** + * Category with parent-child hierarchy + */ +create persistent entity Module.Category ( + Id: long not null unique, + CategoryName: string(200) not null +); + +/** + * Parent category link (self-reference) + */ +create association Module.Category_ParentCategory +from Module.Category to Module.Category +type reference +owner default; +``` + +### ALTER ENTITY (Incremental Modifications) + +Use `alter entity` to make targeted changes to existing entities without redefining the entire entity: + +```sql +-- Add a new attribute +alter entity Module.Customer + add attribute PhoneNumber: string(20); + +-- Add multiple attributes at once +alter entity Module.Order + add attribute VATRate: decimal + add attribute VATAmount: decimal; + +-- Rename an attribute (preserves data). Every stored reference follows it: +-- microflow create/change members, page attribute widgets, validation rules, +-- access rules -- and XPath constraints too ([CreatedDate > ...]), including +-- ones that reach the entity through an association. Microflow expressions +-- ($Order/CreatedDate) are NOT rewritten -- mxbuild reports those as CE0117, +-- so build afterwards. +alter entity Module.Order + rename attribute CreatedDate to OrderDate; + +-- Drop an attribute +alter entity Module.Product + drop attribute LegacyCode; + +-- Modify attribute type +alter entity Module.Customer + modify attribute Address: string(500); + +-- Modify attribute constraints. MODIFY applies the constraints you specify and +-- preserves the ones you don't: +-- NULLABLE -> make a required attribute optional (removes NOT NULL) +-- NOT NULL -> make an optional attribute required +-- UNIQUE -> add a uniqueness constraint +-- DEFAULT x -> set/replace the default +alter entity Module.Customer + modify attribute Email: string(200) nullable; -- Email is now optional +alter entity Module.Customer + modify attribute Code: string(20) not null unique; + +-- Set entity documentation +alter entity Module.Customer + set documentation 'Core customer entity for CRM module'; + +-- Add an index +alter entity Module.Customer + add index idx_email (Email asc); + +-- Reposition entity on domain model canvas +alter entity Module.Customer + set position (100, 200); +``` + +**Supported operations:** ADD ATTRIBUTE, RENAME ATTRIBUTE, MODIFY ATTRIBUTE (type + `NULLABLE`/`NOT NULL`/`UNIQUE`/`DEFAULT` constraints), DROP ATTRIBUTE, SET DOCUMENTATION, SET COMMENT, ADD INDEX, DROP INDEX, SET POSITION. + +> **`MODIFY ATTRIBUTE` always takes a type** — restate it even when you only want +> to change a constraint. Its type slot accepts a bare qualified name, so a +> clause written in the type position is read as a type name: +> `MODIFY ATTRIBUTE X SET DEFAULT 0` treats `SET` as the type. mxcli now refuses +> that; before it did, the statement rewrote the attribute to an enumeration and +> produced a project Mendix could not open (#910). +> +> To clear a default value use **`DROP DEFAULT ON ATTRIBUTE `**. + +### Entity Positioning Guidelines + +When creating or repositioning entities, follow these layout rules for readable domain models: + +- **Horizontal spacing:** 350px between columns (x = 50, 400, 750, 1100, ...) +- **Vertical spacing:** calculate per-column based on the entity above: `y = previous_y + 50 + (previous_entity_attribute_count * 20)` +- Entity header is ~40px, each attribute adds ~20px of height, plus ~50px padding +- **Position column-by-column**, not in rigid rows — avoids wasting space when entities have different attribute counts +- **Place related entities** in the same column or adjacent columns so associations are short + +Example layout for entities with varying attribute counts: + +``` +column 1 (x=50): column 2 (x=400): + entity A (4 attrs) entity C (14 attrs) + y=50 y=50 + + entity B (10 attrs) entity D (3 attrs) + y=180 (50+50+4*20) y=380 (50+50+14*20) +``` + +```sql +-- Position entities after creation +alter entity Module.EntityA set position (50, 50); +alter entity Module.EntityB set position (50, 180); +alter entity Module.EntityC set position (400, 50); +alter entity Module.EntityD set position (400, 380); +``` + +### Entity Migration with CREATE OR MODIFY + +Use `create or modify` to update existing entities without losing data. The REPL computes differences and applies incremental changes. + +```sql +/** + * Customer entity migration - rename CustomerName to FullName + */ +create or modify persistent entity Module.Customer ( + /** Unique identifier (unchanged) */ + CustomerId: long not null unique, + + /** Renamed from CustomerName - data preserved */ + @RenamedFrom('CustomerName') + FullName: string(200) not null, + + /** New field */ + Email: string(255) unique, + + /** Type widened from String(100) to String(200) */ + Address: string(200) +); +``` + +**Key features:** +- `@RenamedFrom('oldName')` - renames attribute, preserves data +- Auto-removes attributes not in new definition +- Allows compatible type changes (e.g., String length increase) +- Preserves entity UUID (no data loss) + +### Status-Driven Entity +```sql +-- Status enumeration +create enumeration Module.TaskStatus ( + Todo 'To Do', + InProgress 'In Progress', + Done 'Done' +); + +-- Entity with status +create persistent entity Module.Task ( + Id: long not null unique, + TaskName: string(200) not null, + status: enumeration(Module.TaskStatus) not null +); +``` diff --git a/.claude/skills/mendix/generate-domain-model/reference/syntax.md b/.claude/skills/mendix/generate-domain-model/reference/syntax.md new file mode 100644 index 000000000..a5f09dd8c --- /dev/null +++ b/.claude/skills/mendix/generate-domain-model/reference/syntax.md @@ -0,0 +1,616 @@ +# Domain model syntax reference + +Supporting reference for [generate-domain-model](../SKILL.md). + +## MDL Syntax Reference + +**CRITICAL: All CREATE statements MUST have JavaDoc-style documentation** + +Every CREATE statement (modules, entities, associations, enumerations, microflows) should have a /** ... */ comment explaining its purpose. This is essential for: +- Team collaboration and knowledge transfer +- Understanding domain model structure +- Long-term maintainability +- Auto-generated documentation + +### Module Creation + +```sql +/** + * Module for financial transaction management + * + * Handles accounts, transactions, budgets, and reporting. + * + * @since 1.0.0 + */ +create module Finance; +``` + +### Minimap Section Headers (MARK Comments) + +**IMPORTANT: Large MDL files (300+ lines) MUST use MARK comments for navigation** + +Use `-- MARK: Section Name` comments to create collapsible sections in code editors. This dramatically improves navigation and organization in large domain model files. + +**Format**: `-- MARK: Section Name` + +**Required for files:** +- 300+ lines: At least 3 MARK comments +- 500+ lines: At least 5 MARK comments + +**Recommended sections:** +```sql +-- MARK: ENUMERATIONS + +-- MARK: CORE ENTITIES + +-- MARK: ASSOCIATIONS + +-- MARK: VIEW ENTITIES + +-- MARK: MICROFLOWS +``` + +**With subsections:** +```sql +-- MARK: - Core Entities (Persistent) + +-- MARK: - View Entities for Reporting +``` + +**Benefits:** +- Creates outline/minimap view in VS Code, Xcode-style editors +- Makes large files navigable with jump-to-section +- Groups related code logically +- Improves team collaboration on complex models + +### Enumerations + +```sql +/** + * Transaction type classification + * + * Categorizes financial transactions as income or expense + * for proper accounting and reporting. + * + * @since 1.0.0 + */ +create enumeration Module.TransactionType ( + INCOME 'Income', + EXPENSE 'Expense' +); +``` + +**Editing an existing enumeration** — use `alter enumeration`, never drop + recreate +(a drop is blocked while the enum is referenced by an attribute): + +```sql +alter enumeration Module.TransactionType add value REFUND caption 'Refund'; +alter enumeration Module.TransactionType rename value INCOME to CREDIT; -- changes the name/key +alter enumeration Module.TransactionType modify value EXPENSE caption 'Expense / Debit'; -- caption only, name unchanged +alter enumeration Module.TransactionType drop value REFUND; +``` + +`modify value … caption` re-captions in place — the value keeps its identity, so it +works even while the enumeration is in use. (Value names in `alter` must be plain +identifiers; a value whose name is a reserved word can't be targeted by `alter`.) + +### Entities + +**IMPORTANT: All entities MUST have @Position annotation** + +The `@position(x, y)` annotation specifies where the entity appears in the domain model diagram. Without it, entities appear at (0,0) or random locations. + +**Position Guidelines:** +- Use increments of 50 or 100 for spacing (e.g., 100, 200, 300) +- Leave space between entities (at least 200 pixels) +- Organize related entities in logical groups +- Example layout: Categories at y=100, Transactions at y=300, Reports at y=500 + +**Association line anchors** — where the connector attaches to each entity box — +are set with `@anchor`, as a **percentage of the box** (0..100, whole numbers): + +```sql +@anchor(from: (0, 54), to: (100, 54)) +create association Sales.Order_Customer + from Sales.Order to Sales.Customer; +``` + +`from` is the anchor on the FROM entity's box, `to` the anchor on the TO +entity's. `(0, 50)` is the middle of the left edge, `(100, 50)` the middle of the +right, `(50, 100)` the bottom centre. + +Retune a line without restating the association: + +```sql +alter association Sales.Order_Customer set anchor from (50, 100) to (50, 0); +``` + +**Naming an end sets it; not naming one preserves what is stored.** An +association written without `@anchor` keeps whatever the line was dragged to in +Studio Pro, so a `create or modify association` about the delete behaviour never +flattens someone's layout. `describe association` re-emits a non-default pair as +the same `@anchor(...)` annotation, so describe → edit → exec round-trips. + +Cross-module associations have no anchors at all — Mendix stores none, and +`set anchor` on one is refused. + +#### Persistent Entity + +```sql +/** + * Entity description + * + * Detailed explanation of what this entity represents. + * + * @since 1.0.0 + * @see Module.RelatedEntity + */ +@position(100, 100) +create persistent entity Module.EntityName ( + /** Unique identifier */ + Id: long not null error 'ID is required' unique error 'ID must be unique', + /** Attribute description */ + attributename: string(200) not null error 'Attribute name is required', + /** Numeric value */ + Amount: decimal, + /** Date field */ + CreationDate: date, + /** Boolean flag */ + IsActive: boolean not null error 'IsActive flag is required' default true, + /** Enumeration field */ + status: enumeration(Module.StatusEnum) not null error 'Status is required' +); +``` + +#### Entity Indexes (Performance Optimization) + +**CRITICAL: INDEX syntax goes AFTER the closing parenthesis, with NO comma before** + +Indexes improve query performance for frequently filtered or sorted columns. Add them to persistent entities when: +- Column is used in WHERE clauses frequently +- Column is used for sorting (ORDER BY) +- Composite indexes for multi-column filters + +**Syntax:** +```sql +create persistent entity Module.Transaction ( + TransactionDate: datetime not null, + status: enumeration(Module.Status) not null, + Amount: decimal not null, + IsRecurring: boolean default false +) +index (TransactionDate desc) +index (status, TransactionDate) +index (IsRecurring); +``` + +**Index Guidelines:** +- **Position**: AFTER closing parenthesis, NO comma before first INDEX +- **No names**: Unlike SQL CREATE INDEX, MDL indexes don't have names +- **Sort direction**: ASC or DESC are optional (default is ASC) +- **Composite indexes**: Order matters - put most selective columns first +- **Limit**: Don't over-index - each index has storage/write overhead + +**Common index patterns:** +- Date fields: `index (CreatedDate desc)` - for recent-first queries +- Status filters: `index (status, CreatedDate desc)` - for filtered date ranges +- Boolean flags: `index (IsActive)` - for active/inactive filtering +- Foreign keys: Automatically indexed by associations + +#### Entity Generalization (EXTENDS) + +**CRITICAL: EXTENDS goes BEFORE the opening parenthesis, not after!** + +Use `extends` to inherit from a parent entity. Common for file/image storage using System entities. + +```sql +-- Correct: EXTENDS before ( +create persistent entity Module.ProductPhoto extends System.Image ( + PhotoCaption: string(200), + SortOrder: integer default 0 +); + +-- Correct: File document specialization +create persistent entity Module.Attachment extends System.FileDocument ( + AttachmentDescription: string(500) +); + +-- Correct: Custom entity inheritance +create persistent entity Module.Employee extends Module.Person ( + EmployeeNumber: string(20) +); +``` + +**Wrong** (parse error): +```sql +-- EXTENDS after ) = parse error! +create persistent entity Module.Photo ( + PhotoCaption: string(200) +) extends System.Image; +``` + +**Note:** `mxcli syntax entity` output may show EXTENDS after `)` — this is misleading. Always place EXTENDS before `(`. + +**Security follows inheritance.** Mendix inheritance is multi-table: all of the +parent's attributes are members of the child, so a specialized entity's access rule +must cover them. Grant an inherited member exactly like one of the entity's own — +`grant Module.Viewer on Module.Attachment (read (AttachmentDescription, "Name", Size));` +— and `read *` / `write *` cover them too. Skipping them is Mendix CE0066 "Entity +access is out of date". The one exception is entities extending `System.User`, whose +inherited platform members Mendix manages and which must not be granted. See +`manage-security`. + +#### System Attributes (Auditing) + +Mendix supports four built-in auditing properties on persistent entities. Declare them as regular attributes using pseudo-types (like `autonumber`): + +| Pseudo-Type | System Attribute | Set When | +|-------------|-----------------|----------| +| `autoowner` | `System.owner` (→ System.User) | Object created | +| `autochangedby` | `System.changedBy` (→ System.User) | Every commit | +| `autocreateddate` | `CreatedDate` (DateTime) | Object created | +| `autochangeddate` | `ChangedDate` (DateTime) | Every commit | + +```sql +/** + * Order with full audit trail + */ +create persistent entity Sales.Order ( + OrderNumber: autonumber default 1, + TotalAmount: decimal not null, + status: enumeration(Sales.OrderStatus) not null, + owner: autoowner, + ChangedBy: autochangedby, + CreatedDate: autocreateddate, + ChangedDate: autochangeddate +); +``` + +To enable/disable on existing entities, use ALTER ENTITY ADD/DROP ATTRIBUTE: + +```sql +alter entity Sales.Order add attribute owner: autoowner; +alter entity Sales.Order add attribute ChangedDate: autochangeddate; +alter entity Sales.Order drop attribute ChangedBy; +``` + +**When to use auditing:** +- Compliance/regulated domains (finance, healthcare) — use all four +- User-generated content — use AutoOwner for ownership-based access rules +- "Recently modified" lists — use AutoChangedDate +- Avoid on high-volume system tables (every write touches the audit columns) + +#### Non-Persistent Entity + +**IMPORTANT: Non-persistent entities cannot have validation rules** (`not null error`, `unique error`) on attributes. They can only have `default` values. + +```sql +/** + * Non-persistent entity description + * + * @since 1.0.0 + */ +@position(200, 100) +create non-persistent entity Module.TemporaryData ( + SessionId: string(100), + data: string(1000), + IsActive: boolean default false +); +``` + +#### View Entity (with OQL) + +```sql +/** + * View entity description + * + * @since 1.0.0 + */ +@position(300, 500) +create view entity Module.ViewName ( + Attribute1: type, + Attribute2: type +) as ( + select + e.Id as Id, + e.Name as Name, + e.Amount as Amount + from Module.Entity as e + where e.IsActive = true +); +``` + +**Enumeration Comparisons in OQL:** + +When comparing enumeration attributes in OQL WHERE clauses, use the **enumeration value** (identifier), not the caption: + +```sql +-- Enumeration definition +create enumeration Module.OrderStatus ( + PENDING 'Pending', + PROCESSING 'Processing', + CANCELLED 'Cancelled' +); + +-- OQL comparison - use the VALUE, not the caption +where e.Status != 'CANCELLED' -- Correct: uses enum value +where e.Status != 'Cancelled' -- Wrong: this is the caption +``` + +### Entity Event Handlers + +Microflows can run before/after entity Create, Commit, Delete, or Rollback. Use the optional `raise error` clause to make a handler act as a validation microflow — if it returns false, the operation is aborted. + +```sql +-- In CREATE ENTITY (handlers go after attributes/indexes) +create persistent entity Sales.Order ( + Total: decimal, + status: string(50) +) +on before commit call Sales.ACT_ValidateOrder raise error +on after create call Sales.ACT_InitDefaults; + +-- Add via ALTER ENTITY +alter entity Sales.Order + add event handler on before delete call Sales.ACT_CheckCanDelete raise error; + +-- Drop via ALTER ENTITY +alter entity Sales.Order + drop event handler on before commit; +``` + +**Moments**: `before`, `after` +**Events**: `create`, `commit`, `delete`, `rollback` + +Each (Moment, Event) combination can only have one handler per entity. The microflow must exist (the executor validates the reference). `raise error` is optional — without it, the handler runs but its return value doesn't affect the operation. + +### Associations + +**CRITICAL: Association Directionality** + +In Mendix, associations are defined **FROM the entity that contains the foreign key TO the entity that is referenced**. + +Think of it like this: +- A `Transaction` knows which `Account` it belongs to → Transaction contains the foreign key +- Therefore: `from Transaction to Account` +- **NOT** `from Account to Transaction` ❌ + +**Common Patterns**: + +```sql +-- ❌ INCORRECT: Account doesn't store transaction references +create association Finance.Account_Transaction +from Finance.Account to Finance.Transaction +type reference; + +-- ✅ CORRECT: Transaction stores the account reference (foreign key) +create association Finance.Transaction_Account +from Finance.Transaction to Finance.Account +type reference; + +-- ✅ One-to-Many: Customer has many Orders (each order knows its customer) +create association Sales.Order_Customer +from Sales.Order to Sales.Customer +type reference; + +-- ✅ Many-to-Many: Use ReferenceSet and choose which side stores the relationship +create association Sales.Order_Products +from Sales.Order to Sales.Product +type ReferenceSet +owner both; +``` + +**Full Association Syntax**: + +```sql +/** + * Association description + * + * Explain the relationship and directionality. + * + * @since 1.0.0 + */ +create association Module.EntityWithFK_ReferencedEntity +from Module.EntityWithFK to Module.ReferencedEntity +type reference +owner default +delete_behavior DELETE_BUT_KEEP_REFERENCES +comment 'Additional documentation'; +``` + +**Idempotency**: plain `create association` is **not** idempotent — re-running it +errors with `association already exists`, which aborts the rest of the script (and +any associations defined *after* it are never created). Write **`create or modify +association`** from the first draft — same clauses, but re-running is a no-op: + +```sql +create or modify association Module.Child_Parent +from Module.Child to Module.Parent +type reference; +``` + +**Association Types**: +- `reference` - One-to-one or many-to-one (foreign key on FROM entity) +- `ReferenceSet` - One-to-many or many-to-many (collection) + +**Owner Options**: +- `default` - Standard ownership (FROM entity owns the reference) +- `both` - Both sides own the association (bidirectional) +- `Parent` - Only parent (TO) entity owns +- `Child` - Only child (FROM) entity owns + +> **Use `default` ownership for a normal to-one reference.** Reserve `owner both` +> for a `ReferenceSet` (many-to-many). On a plain `type reference`, `owner both` +> makes the association navigable **to-one from *both* sides** — so the reverse +> direction is a single object, not a collection. A **list** widget (listview/ +> datagrid/gallery) over the reverse then fails MxBuild **CE8812** "A grid +> association path must result in a list." (a DataView over the reverse is fine — +> it's a single object). With `default` ownership, the reverse is the expected +> to-many collection and a list widget works. See master-detail-pages for the +> widget patterns. + +**Delete Behaviors**: +- `DELETE_AND_REFERENCES` - Delete object and all referencing objects +- `DELETE_BUT_KEEP_REFERENCES` - Delete object, keep references (nullify) +- `DELETE_IF_NO_REFERENCES` - Only delete if no objects reference it +- `cascade` - Cascade delete to associated objects +- `prevent` - Prevent deletion if references exist + +**Naming Convention**: `{FromEntity}_{ToEntity}` (e.g., `Order_Customer`, `Transaction_Account`) + +#### Calculated Attributes + +Calculated attributes derive their value from a microflow at runtime. Use `calculated by Module.Microflow` to specify the calculation microflow. + +**IMPORTANT: CALCULATED attributes are only supported on PERSISTENT entities.** Using CALCULATED on non-persistent entities will produce a validation error. + +```sql +@position(100, 100) +create persistent entity Module.OrderLine ( + /** Unit price */ + UnitPrice: decimal not null, + /** Quantity ordered */ + Quantity: integer not null, + /** Total price, calculated by microflow */ + TotalPrice: decimal calculated by Module.CalcTotalPrice +); +``` + +**Syntax variants:** +- `calculated by Module.Microflow` — recommended, binds the calculation microflow directly +- `calculated Module.Microflow` — also valid (`by` keyword is optional) +- `calculated` — bare form, marks as calculated but requires manual microflow binding in Studio Pro + +**The microflow's signature is checked, and mxcli refuses a mismatch before +writing** — Mendix reports these as **CE7247** at build time (verified on 11.13.0): + +| Microflow | Result | +|-----------|--------| +| takes the owning entity (`$Order: Module.Order`) | ✅ stored with `PassEntity = true` | +| takes **no** parameter | ✅ stored with `PassEntity = false` — equally valid | +| takes a *different* entity | ❌ refused: CE7247 *"Microflow parameter 'X' should be of type Module.Order."* | +| takes two or more parameters | ❌ refused | +| returns the wrong type | ❌ refused: CE7247 *"Microflow return type should be …"* | +| returns `Long` for an `integer` attribute (or vice versa) | ✅ accepted — Integer and Long are one family here | + +A microflow **created earlier in the same script** cannot be inspected yet, so +its signature is not checked; the build has the last word on those. + +> **Before mxcli 0.17 the binding was silently discarded** on the default +> engine: the attribute was written as an ordinary stored value, `mx check` +> reported 0 errors, and the attribute stayed empty at runtime (#917). If you +> have attributes that were declared `calculated by` and never calculated, they +> need re-running through a current mxcli — re-executing the same statement is +> enough. + +### Data Types + +| Type | Example | Description | +|------|---------|-------------| +| `string(length)` | `string(200)` | Text field with max length | +| `integer` | `integer` | 32-bit integer | +| `long` | `long` | 64-bit integer (use for IDs) | +| `decimal` | `decimal` | Decimal number | +| `boolean` | `boolean` | True/false | +| `datetime` | `datetime` | Date and time | +| `date` | `date` | Date only | +| `binary` | `binary` | Binary data | +| `autonumber` | `autonumber default 1` | Auto-incrementing number (requires DEFAULT start value) | +| `enumeration(Module.Enum)` | `enumeration(Shop.Status)` | Enumeration reference | + +### Constraints + +**Basic Constraints:** +- `not null` - Field is required +- `unique` - Value must be unique +- `default value` - Default value + +**Validation Error Messages:** + +Each constraint can have a custom error message using `error 'message'` syntax: + +```sql +create persistent entity Module.Customer ( + /** Customer name - required with custom error */ + Name: string(200) not null error 'Name is required', + /** Email - required and unique with separate error messages */ + Email: string(200) not null error 'Email is required' unique error 'Email must be unique', + /** Age with default value */ + Age: integer default 0, + /** Active status flag */ + IsActive: boolean not null error 'IsActive flag is required' default true +); +``` + +**Error Message Guidelines:** +- Place `error 'message'` immediately after the constraint +- Multiple constraints can each have their own error message +- Keep messages clear and user-friendly +- Follow the pattern: `not null error 'X is required'` for required fields +- For UNIQUE: `unique error 'X must be unique'` +- Error messages are shown to end users during validation + +**Common patterns:** +```sql +-- Required field +Name: string(200) not null error 'Name is required', + +-- Required and unique +Email: string(200) not null error 'Email is required' unique error 'Email must be unique', + +-- Required with default +IsActive: boolean not null error 'IsActive flag is required' default true, + +-- Enum with required error +status: enumeration(Module.Status) not null error 'Status is required', + +-- Enum with default value (use fully qualified Module.Enum.Value) +Priority: enumeration(Module.Priority) default Module.Priority.Normal +``` +## Reserved Keywords + +**Best practice: Always quote all identifiers** (entity names, attribute names) with double quotes. This escapes every **MDL parser** keyword conflict — quotes are stripped automatically by the parser. So `"create"`, `"status"`, `"end"` become valid attribute names. + +```sql +create persistent entity Module."VATRate" ( + "create": datetime, + "Rate": decimal, + "status": string(50) +); +``` + +> **Caveat — quoting does not exempt *platform*-reserved member names.** Some names are +> reserved by the Mendix *platform*, not just the MDL parser, and are rejected **even when +> quoted** (the check strips the quotes and still flags them): `Type` (CE7247, MDL021), +> the audit attributes `CreatedDate` / `ChangedDate` / `Owner` / `ChangedBy` (MDL020 — use +> the `AutoCreatedDate` / `AutoChangedDate` / `AutoOwner` / `AutoChangedBy` pseudo-types +> instead), plus `ID`, `GUID`, `CurrentUser` and the Java-keyword list. `"Type": String` +> fails MDL021 — rename to `ResourceType` / `TypeValue`. + +Both `"Name"` and `` `Name` `` syntax are supported. Prefer double quotes for consistency. + +**Boolean attributes** auto-default to `false` when no `default` is specified: +```sql +create persistent entity Module.Item ( + IsActive: boolean, -- auto-defaults to false + IsPublished: boolean default true +); +``` +## Entity Positioning + +Use `@position(x, y)` to control layout in Studio Pro: +- Place related entities near each other +- Use consistent spacing (e.g., 250 pixels horizontal, 200 vertical) +- Group by domain concept + +Example layout: +```sql +@position(50, 50) -- Top-left: Core entity +create persistent entity Module.Customer (...); + +@position(300, 50) -- Same row: Related entity +create persistent entity Module.Address (...); + +@position(50, 250) -- Below: Dependent entity +create persistent entity Module.Order (...); +``` diff --git a/.claude/skills/mendix/java-actions/SKILL.md b/.claude/skills/mendix/java-actions/SKILL.md index 8ecbed84d..e9aa991e6 100644 --- a/.claude/skills/mendix/java-actions/SKILL.md +++ b/.claude/skills/mendix/java-actions/SKILL.md @@ -7,6 +7,18 @@ description: "Create and call custom Java actions — extending Mendix with serv This skill provides comprehensive guidance for creating and using custom Java actions in Mendix projects. +## Reference files + +`SKILL.md` covers declaring a Java action in MDL and calling it from a microflow — +the parts that touch the model. The Java side is next door: + +- [`reference/writing-java.md`](reference/writing-java.md) — creating the action + in Studio Pro, writing the `executeAction` body, the Core API surface you have, + and the recurring patterns (HTTP calls, file handling, batch work). +- [`reference/best-practices.md`](reference/best-practices.md) — error handling, + transactions and rollback, logging, threading, security, and performance. Read + it before shipping a Java action that touches data or an external system. + ## When to Use This Skill Use this skill when: @@ -23,214 +35,6 @@ Java actions allow you to extend Mendix with custom Java code. The workflow is: 2. **Implement** the Java code in Eclipse/IDE 3. **Call** the Java action from microflows using MDL -## Part 1: Creating Java Actions in Studio Pro - -### Step 1: Create the Java Action - -In Studio Pro: -1. Right-click module in Project Explorer → **Add other** → **Java action** -2. Name using convention: `JA_ActionName` (e.g., `JA_CalculateTax`, `JA_SendEmail`) -3. Define parameters and return type - -### Step 2: Define Parameters - -| Parameter Type | Mendix Type | Java Type | -|----------------|-------------|-----------| -| String | String | `java.lang.String` | -| Integer | Integer/Long | `java.lang.Long` | -| Decimal | Decimal | `java.math.BigDecimal` | -| Boolean | Boolean | `java.lang.Boolean` | -| DateTime | Date and time | `java.util.Date` | -| Object | Entity | `IMendixObject` | -| List | List of Entity | `java.util.List` | -| StringTemplate(Sql) | SQL template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | -| StringTemplate(Text) | Text template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | - -**Note:** `stringtemplate(sql)` and `stringtemplate(text)` are specialized types for parameterized SQL/OQL queries and text templates respectively. - -### Step 3: Export for Eclipse - -1. Menu → **App** → **Deploy for Eclipse** -2. Open project in Eclipse -3. Find Java action in `javasource//actions/` - -## Part 2: Writing Java Action Code - -### Basic Structure - -```java -package mymodule.actions; - -import com.mendix.systemwideinterfaces.core.IContext; -import com.mendix.webui.CustomJavaAction; -import com.mendix.core.Core; -import com.mendix.systemwideinterfaces.core.IMendixObject; - -public class JA_CalculateTax extends CustomJavaAction -{ - private java.math.BigDecimal amount; - private java.math.BigDecimal taxRate; - - public JA_CalculateTax(IContext context, java.math.BigDecimal amount, java.math.BigDecimal taxRate) - { - super(context); - this.amount = amount; - this.taxRate = taxRate; - } - - @java.lang.Override - public java.math.BigDecimal executeAction() throws Exception - { - // begin user CODE - if (this.amount == null || this.taxRate == null) { - return java.math.BigDecimal.ZERO; - } - - return this.amount.multiply(this.taxRate); - // end user CODE - } -} -``` - -**CRITICAL**: Only code between `// begin user CODE` and `// end user CODE` is preserved. Everything else is regenerated by Studio Pro. - -### Working with Mendix Objects - -```java -@java.lang.Override -public IMendixObject executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - // create a new object - IMendixObject order = Core.instantiate(context, "Sales.Order"); - order.setValue(context, "OrderNumber", "ORD-" + System.currentTimeMillis()); - order.setValue(context, "OrderDate", new java.util.Date()); - order.setValue(context, "status", "Draft"); - order.setValue(context, "TotalAmount", java.math.BigDecimal.ZERO); - - // commit to database - Core.commit(context, order); - - return order; - // end user CODE -} -``` - -### Core API Reference - -| Method | Description | -|--------|-------------| -| `Core.instantiate(context, "Module.Entity")` | Create new object | -| `Core.commit(context, object)` | Save to database | -| `Core.commitWithoutEvents(context, object)` | Save without triggering events | -| `Core.delete(context, object)` | Delete object | -| `Core.rollback(context, object)` | Discard uncommitted changes | -| `Core.retrieveId(context, id)` | Retrieve by GUID | -| `Core.createXPathQuery(xpath).execute(context)` | Query with XPath | -| `Core.microflowCall(name).execute(context)` | Call microflow | - -### Reading and Writing Attributes - -```java -// Reading values -string name = (string) order.getValue(context, "Name"); -java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); -boolean isActive = (boolean) order.getValue(context, "IsActive"); -java.util.Date orderDate = (java.util.Date) order.getValue(context, "OrderDate"); - -// Writing values -order.setValue(context, "Name", "New Order"); -order.setValue(context, "Amount", new java.math.BigDecimal("100.00")); -order.setValue(context, "IsActive", true); -order.setValue(context, "ProcessedDate", new java.util.Date()); -``` - -### Working with Associations - -```java -// set association (reference) -IMendixObject customer = Core.createXPathQuery("//Sales.Customer[CustomerCode = 'CUST001']") - .execute(context).get(0); -order.setValue(context, "Sales.Order_Customer", customer.getId()); - -// get associated object -IMendixIdentifier customerId = order.getValue(context, "Sales.Order_Customer"); -if (customerId != null) { - IMendixObject relatedCustomer = Core.retrieveId(context, customerId); - string customerName = (string) relatedCustomer.getValue(context, "Name"); -} -``` - -### Working with Lists - -```java -// retrieve list -list orders = Core.createXPathQuery("//Sales.Order[status = 'Pending']") - .execute(context); - -// Process list -java.math.BigDecimal total = java.math.BigDecimal.ZERO; -for (IMendixObject order : orders) { - java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); - if (amount != null) { - total = total.add(amount); - } -} - -// create list to return -list results = new java.util.ArrayList<>(); -results.add(order1); -results.add(order2); -return results; -``` - -### Error Handling - -```java -@java.lang.Override -public boolean executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - try { - // business logic here - IMendixObject order = Core.instantiate(context, "Sales.Order"); - order.setValue(context, "OrderNumber", generateOrderNumber()); - Core.commit(context, order); - return true; - - } catch (Exception e) { - // log error - Core.getLogger("MyModule").error("Failed to create order: " + e.getMessage(), e); - - // Optionally throw to show error to user - throw new com.mendix.systemwideinterfaces.MendixRuntimeException( - "Could not create order: " + e.getMessage()); - } - // end user CODE -} -``` - -### Logging - -```java -import com.mendix.logging.ILogNode; - -// get logger -ILogNode logger = Core.getLogger("MyModule.MyAction"); - -// log at different levels -logger.trace("Detailed trace message"); -logger.debug("debug information"); -logger.info("Processing started for order: " + orderNumber); -logger.warn("Unusual condition detected"); -logger.error("error processing order", exception); -logger.critical("critical system failure"); -``` - ## Part 2.5: Creating Java Actions in MDL MDL supports defining Java actions with inline Java code using `create java action`. @@ -621,478 +425,6 @@ begin end; ``` -## Part 4: Common Java Action Patterns - -### Pattern 1: Validation Helper - -**Java Action Definition:** -- Name: `JA_ValidateEmail` -- Parameter: `EmailAddress` (String) -- Return: Boolean - -```java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - if (this.EmailAddress == null || this.EmailAddress.trim().isEmpty()) { - return false; - } - - string emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; - return this.EmailAddress.matches(emailRegex); - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Customer.VAL_CustomerEmail($customer: Customer.Customer) -returns boolean as $isValid -begin - $isValid = call java action Customer.JA_ValidateEmail( - EmailAddress = $customer/Email - ); - - if not($isValid) then - validation feedback $customer/Email - message 'Please enter a valid email address'; - end if; - - return $isValid; -end; -``` - -### Pattern 2: External API Call - -**Java Action Definition:** -- Name: `JA_FetchExchangeRate` -- Parameters: `FromCurrency` (String), `ToCurrency` (String) -- Return: Decimal - -```java -@java.lang.Override -public java.math.BigDecimal executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - try { - // build api url - string url = "https://api.exchangerate.host/convert?from=" - + this.FromCurrency + "&to=" + this.ToCurrency; - - // Make HTTP request (using your preferred HTTP client) - java.net.HttpURLConnection conn = - (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); - conn.setRequestMethod("get"); - - // Parse response - java.io.BufferedReader reader = new java.io.BufferedReader( - new java.io.InputStreamReader(conn.getInputStream())); - StringBuilder response = new StringBuilder(); - string line; - while ((line = reader.readLine()) != null) { - response.append(line); - } - reader.close(); - - // Parse json and extract rate (simplified) - // in production, use a proper json library - string json = response.toString(); - int rateIndex = json.indexOf("\"result\":"); - if (rateIndex > 0) { - string rateStr = json.substring(rateIndex + 9, json.indexOf(",", rateIndex)); - return new java.math.BigDecimal(rateStr.trim()); - } - - return java.math.BigDecimal.ONE; - - } catch (Exception e) { - Core.getLogger("ExchangeRate").error("Failed to fetch rate", e); - throw new com.mendix.systemwideinterfaces.MendixRuntimeException( - "Could not fetch exchange rate: " + e.getMessage()); - } - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Finance.ACT_ConvertCurrency( - $amount: decimal, - $fromCurrency: string, - $toCurrency: string -) -returns decimal as $convertedAmount -begin - declare $rate decimal; - - $rate = call java action Finance.JA_FetchExchangeRate( - FromCurrency = $fromCurrency, - ToCurrency = $toCurrency - ); - - set $convertedAmount = $amount * $rate; - return $convertedAmount; -end; -``` - -### Pattern 3: File Processing - -**Java Action Definition:** -- Name: `JA_GeneratePDF` -- Parameters: `Order` (Sales.Order entity), `TemplateName` (String) -- Return: System.FileDocument - -```java -@java.lang.Override -public IMendixObject executeAction() throws Exception -{ - // begin user CODE - IContext context = getContext(); - - // get order data - string orderNumber = (string) this.Order.getValue(context, "OrderNumber"); - java.math.BigDecimal total = (java.math.BigDecimal) this.Order.getValue(context, "TotalAmount"); - - // generate PDF content (using iText or similar library) - byte[] pdfContent = generatePdfBytes(orderNumber, total); - - // create FileDocument - IMendixObject fileDoc = Core.instantiate(context, "System.FileDocument"); - fileDoc.setValue(context, "Name", "Order_" + orderNumber + ".pdf"); - Core.storeFileDocumentContent(context, fileDoc, - new java.io.ByteArrayInputStream(pdfContent)); - - Core.commit(context, fileDoc); - - return fileDoc; - // end user CODE -} -``` - -**MDL Usage:** -```mdl -create microflow Sales.ACT_GenerateOrderPDF($order: Sales.Order) -returns System.FileDocument as $pdfFile -begin - $pdfFile = call java action Sales.JA_GeneratePDF( - Order = $order, - TemplateName = 'OrderConfirmation' - ); - - log info node 'Sales' 'Generated PDF for order: ' + $order/OrderNumber; - - return $pdfFile; -end; -``` - -## Part 5: Best Practices - -### Naming Conventions - -| Element | Convention | Example | -|---------|------------|---------| -| Java Action | `JA_` prefix + PascalCase | `JA_CalculateTax`, `JA_SendEmail` | -| Module | Business domain name | `Finance`, `Integration`, `Utils` | -| Parameters | PascalCase, descriptive | `OrderAmount`, `CustomerEmail` | - -### Code Organization (Recommended) - -**Keep Java action code minimal** - only handle parameter extraction and delegation. Put the actual implementation in separate classes under `.impl`. - -**Why?** -- Code between `begin user CODE` and `end user CODE` is preserved, but it's limited space -- Implementation classes are fully under your control (not regenerated) -- Easier to unit test implementation logic separately -- Better code organization and reusability - -**Package Structure:** -``` -javasource/ -├── mymodule/ -│ ├── actions/ -│ │ └── JA_ProcessOrder.java # Generated action (minimal code) -│ └── impl/ -│ ├── processorder/ -│ │ ├── OrderProcessor.java # Main implementation -│ │ ├── OrderValidator.java # validation logic -│ │ └── OrderNotifier.java # notification logic -│ └── shared/ -│ └── EmailService.java # Shared utilities -``` - -**Example - Java Action (Thin Wrapper):** -```java -// in javasource/mymodule/actions/JA_ProcessOrder.java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - // Delegate to implementation class - keep this minimal! - return new mymodule.impl.processorder.OrderProcessor(getContext()) - .process(this.Order, this.SendNotification); - // end user CODE -} -``` - -**Example - Implementation Class (Testable Design):** - -The key to testability is separating **pure business logic** from **Mendix API calls**. Use interfaces for data access so you can mock them in tests. - -```java -// in javasource/mymodule/impl/processorder/OrderProcessor.java -package mymodule.impl.processorder; - -import java.math.BigDecimal; -import java.util.Date; - -/** - * Pure business logic - NO Mendix dependencies! - * Can be tested with plain JUnit without running Mendix. - */ -public class OrderProcessor { - - public ProcessResult process(OrderData order, boolean sendNotification) { - // Validate - pure java logic - if (order.getOrderNumber() == null || order.getOrderNumber().isEmpty()) { - return ProcessResult.failure("Order number is required"); - } - if (order.getTotalAmount() == null || order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { - return ProcessResult.failure("Order amount must be positive"); - } - - // Calculate - pure java logic - BigDecimal tax = calculateTax(order.getTotalAmount(), order.getTaxRate()); - BigDecimal finalAmount = order.getTotalAmount().add(tax); - - // return result (actual persistence happens in adapter) - return ProcessResult.success(finalAmount, tax, new date()); - } - - public BigDecimal calculateTax(BigDecimal amount, BigDecimal rate) { - if (amount == null || rate == null) { - return BigDecimal.ZERO; - } - return amount.multiply(rate).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); - } -} -``` - -```java -// in javasource/mymodule/impl/processorder/OrderData.java -package mymodule.impl.processorder; - -import java.math.BigDecimal; - -/** - * Plain Java data object - no Mendix dependencies. - */ -public class OrderData { - private string orderNumber; - private BigDecimal totalAmount; - private BigDecimal taxRate; - - // Constructor, getters, setters... - public OrderData(string orderNumber, BigDecimal totalAmount, BigDecimal taxRate) { - this.orderNumber = orderNumber; - this.totalAmount = totalAmount; - this.taxRate = taxRate; - } - - public string getOrderNumber() { return orderNumber; } - public BigDecimal getTotalAmount() { return totalAmount; } - public BigDecimal getTaxRate() { return taxRate; } -} -``` - -```java -// in javasource/mymodule/impl/processorder/MendixOrderAdapter.java -package mymodule.impl.processorder; - -import com.mendix.systemwideinterfaces.core.IContext; -import com.mendix.systemwideinterfaces.core.IMendixObject; -import com.mendix.core.Core; -import java.math.BigDecimal; - -/** - * Adapter: converts between Mendix objects and pure Java objects. - * This is the ONLY class that touches Mendix APIs. - */ -public class MendixOrderAdapter { - private final IContext context; - - public MendixOrderAdapter(IContext context) { - this.context = context; - } - - public OrderData toOrderData(IMendixObject mendixOrder) { - return new OrderData( - (string) mendixOrder.getValue(context, "OrderNumber"), - (BigDecimal) mendixOrder.getValue(context, "TotalAmount"), - (BigDecimal) mendixOrder.getValue(context, "TaxRate") - ); - } - - public void applyResult(IMendixObject mendixOrder, ProcessResult result) throws Exception { - mendixOrder.setValue(context, "status", "Processed"); - mendixOrder.setValue(context, "FinalAmount", result.getFinalAmount()); - mendixOrder.setValue(context, "TaxAmount", result.getTaxAmount()); - mendixOrder.setValue(context, "ProcessedDate", result.getProcessedDate()); - Core.commit(context, mendixOrder); - } -} -``` - -**Example - Java Action (Wiring Only):** -```java -// in javasource/mymodule/actions/JA_ProcessOrder.java -@java.lang.Override -public java.lang.Boolean executeAction() throws Exception -{ - // begin user CODE - // Wire up adapter and processor - MendixOrderAdapter adapter = new MendixOrderAdapter(getContext()); - OrderProcessor processor = new OrderProcessor(); - - // Convert Mendix object to plain java object - OrderData orderData = adapter.toOrderData(this.Order); - - // Process (pure java - no Mendix dependencies) - ProcessResult result = processor.process(orderData, this.SendNotification); - - if (result.isSuccess()) { - // apply result back to Mendix object - adapter.applyResult(this.Order, result); - return true; - } else { - Core.getLogger("MyModule").warn("Order processing failed: " + result.getMessage()); - return false; - } - // end user CODE -} -``` - -**Example - Unit Test (No Mendix Runtime Required):** -```java -// in javasource/mymodule/impl/processorder/OrderProcessorTest.java -package mymodule.impl.processorder; - -import org.junit.Test; -import static org.junit.Assert.*; -import java.math.BigDecimal; - -public class OrderProcessorTest { - - @Test - public void testProcessValidOrder() { - // Arrange - plain java objects, no mocking needed! - OrderProcessor processor = new OrderProcessor(); - OrderData order = new OrderData("ORD-001", new BigDecimal("100.00"), new BigDecimal("21")); - - // Act - ProcessResult result = processor.process(order, false); - - // Assert - assertTrue(result.isSuccess()); - assertEquals(new BigDecimal("21.00"), result.getTaxAmount()); - assertEquals(new BigDecimal("121.00"), result.getFinalAmount()); - } - - @Test - public void testProcessInvalidOrder_MissingOrderNumber() { - OrderProcessor processor = new OrderProcessor(); - OrderData order = new OrderData(null, new BigDecimal("100.00"), new BigDecimal("21")); - - ProcessResult result = processor.process(order, false); - - assertFalse(result.isSuccess()); - assertEquals("Order number is required", result.getMessage()); - } - - @Test - public void testCalculateTax() { - OrderProcessor processor = new OrderProcessor(); - - BigDecimal tax = processor.calculateTax(new BigDecimal("200.00"), new BigDecimal("10")); - - assertEquals(new BigDecimal("20.00"), tax); - } -} -``` - -**Run tests with Maven or standalone:** -```bash -# from javasource directory -javac -cp .:junit-4.13.jar mymodule/impl/processorder/*.java -java -cp .:junit-4.13.jar org.junit.runner.JUnitCore mymodule.impl.processorder.OrderProcessorTest -``` - -**Benefits:** -- **Testable without Mendix** - Run JUnit tests locally or in CI without Mendix runtime -- **Fast feedback** - Unit tests run in milliseconds, not minutes -- **Clear separation** - Business logic is pure Java; Mendix integration is isolated in adapters -- **Reusable** - `OrderProcessor` can be used in other contexts (batch jobs, REST APIs) -- **Maintainable** - Changes to business logic don't require Mendix knowledge - -### Error Handling Best Practices - -1. **Always wrap in try-catch**: -```java -try { - // business logic -} catch (Exception e) { - Core.getLogger("MyModule").error("operation failed", e); - throw new MendixRuntimeException("user-friendly message: " + e.getMessage()); -} -``` - -2. **Validate inputs early**: -```java -if (this.requiredParam == null) { - throw new IllegalArgumentException("RequiredParam is required"); -} -``` - -3. **Use appropriate log levels**: -- `trace`: Detailed debugging -- `debug`: Development information -- `info`: Normal operations -- `warn`: Potential issues -- `error`: Recoverable errors -- `critical`: System failures - -### Performance Considerations - -1. **Batch operations** when possible: -```java -// Instead of committing one by one -list toCommit = new ArrayList<>(); -for (IMendixObject obj : objects) { - obj.setValue(context, "status", "Processed"); - toCommit.add(obj); -} -Core.commit(context, toCommit); // single batch commit -``` - -2. **Use pagination** for large datasets: -```java -int offset = 0; -int batchSize = 1000; -list batch; -do { - batch = Core.createXPathQuery(xpath).setAmount(batchSize).setOffset(offset).execute(context); - // Process batch - offset += batchSize; -} while (batch.size() == batchSize); -``` - -3. **Cache expensive lookups**: -```java -private static map cache = new ConcurrentHashMap<>(); -``` - ## Validation Checklist Before deploying Java actions, verify: diff --git a/.claude/skills/mendix/java-actions/reference/best-practices.md b/.claude/skills/mendix/java-actions/reference/best-practices.md new file mode 100644 index 000000000..cf348467f --- /dev/null +++ b/.claude/skills/mendix/java-actions/reference/best-practices.md @@ -0,0 +1,308 @@ +# Java action best practices + +Supporting reference for [java-actions](../SKILL.md). + +## Part 5: Best Practices + +### Naming Conventions + +| Element | Convention | Example | +|---------|------------|---------| +| Java Action | `JA_` prefix + PascalCase | `JA_CalculateTax`, `JA_SendEmail` | +| Module | Business domain name | `Finance`, `Integration`, `Utils` | +| Parameters | PascalCase, descriptive | `OrderAmount`, `CustomerEmail` | + +### Code Organization (Recommended) + +**Keep Java action code minimal** - only handle parameter extraction and delegation. Put the actual implementation in separate classes under `.impl`. + +**Why?** +- Code between `begin user CODE` and `end user CODE` is preserved, but it's limited space +- Implementation classes are fully under your control (not regenerated) +- Easier to unit test implementation logic separately +- Better code organization and reusability + +**Package Structure:** +``` +javasource/ +├── mymodule/ +│ ├── actions/ +│ │ └── JA_ProcessOrder.java # Generated action (minimal code) +│ └── impl/ +│ ├── processorder/ +│ │ ├── OrderProcessor.java # Main implementation +│ │ ├── OrderValidator.java # validation logic +│ │ └── OrderNotifier.java # notification logic +│ └── shared/ +│ └── EmailService.java # Shared utilities +``` + +**Example - Java Action (Thin Wrapper):** +```java +// in javasource/mymodule/actions/JA_ProcessOrder.java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + // Delegate to implementation class - keep this minimal! + return new mymodule.impl.processorder.OrderProcessor(getContext()) + .process(this.Order, this.SendNotification); + // end user CODE +} +``` + +**Example - Implementation Class (Testable Design):** + +The key to testability is separating **pure business logic** from **Mendix API calls**. Use interfaces for data access so you can mock them in tests. + +```java +// in javasource/mymodule/impl/processorder/OrderProcessor.java +package mymodule.impl.processorder; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * Pure business logic - NO Mendix dependencies! + * Can be tested with plain JUnit without running Mendix. + */ +public class OrderProcessor { + + public ProcessResult process(OrderData order, boolean sendNotification) { + // Validate - pure java logic + if (order.getOrderNumber() == null || order.getOrderNumber().isEmpty()) { + return ProcessResult.failure("Order number is required"); + } + if (order.getTotalAmount() == null || order.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) { + return ProcessResult.failure("Order amount must be positive"); + } + + // Calculate - pure java logic + BigDecimal tax = calculateTax(order.getTotalAmount(), order.getTaxRate()); + BigDecimal finalAmount = order.getTotalAmount().add(tax); + + // return result (actual persistence happens in adapter) + return ProcessResult.success(finalAmount, tax, new date()); + } + + public BigDecimal calculateTax(BigDecimal amount, BigDecimal rate) { + if (amount == null || rate == null) { + return BigDecimal.ZERO; + } + return amount.multiply(rate).divide(BigDecimal.valueOf(100), 2, java.math.RoundingMode.HALF_UP); + } +} +``` + +```java +// in javasource/mymodule/impl/processorder/OrderData.java +package mymodule.impl.processorder; + +import java.math.BigDecimal; + +/** + * Plain Java data object - no Mendix dependencies. + */ +public class OrderData { + private string orderNumber; + private BigDecimal totalAmount; + private BigDecimal taxRate; + + // Constructor, getters, setters... + public OrderData(string orderNumber, BigDecimal totalAmount, BigDecimal taxRate) { + this.orderNumber = orderNumber; + this.totalAmount = totalAmount; + this.taxRate = taxRate; + } + + public string getOrderNumber() { return orderNumber; } + public BigDecimal getTotalAmount() { return totalAmount; } + public BigDecimal getTaxRate() { return taxRate; } +} +``` + +```java +// in javasource/mymodule/impl/processorder/MendixOrderAdapter.java +package mymodule.impl.processorder; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.systemwideinterfaces.core.IMendixObject; +import com.mendix.core.Core; +import java.math.BigDecimal; + +/** + * Adapter: converts between Mendix objects and pure Java objects. + * This is the ONLY class that touches Mendix APIs. + */ +public class MendixOrderAdapter { + private final IContext context; + + public MendixOrderAdapter(IContext context) { + this.context = context; + } + + public OrderData toOrderData(IMendixObject mendixOrder) { + return new OrderData( + (string) mendixOrder.getValue(context, "OrderNumber"), + (BigDecimal) mendixOrder.getValue(context, "TotalAmount"), + (BigDecimal) mendixOrder.getValue(context, "TaxRate") + ); + } + + public void applyResult(IMendixObject mendixOrder, ProcessResult result) throws Exception { + mendixOrder.setValue(context, "status", "Processed"); + mendixOrder.setValue(context, "FinalAmount", result.getFinalAmount()); + mendixOrder.setValue(context, "TaxAmount", result.getTaxAmount()); + mendixOrder.setValue(context, "ProcessedDate", result.getProcessedDate()); + Core.commit(context, mendixOrder); + } +} +``` + +**Example - Java Action (Wiring Only):** +```java +// in javasource/mymodule/actions/JA_ProcessOrder.java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + // Wire up adapter and processor + MendixOrderAdapter adapter = new MendixOrderAdapter(getContext()); + OrderProcessor processor = new OrderProcessor(); + + // Convert Mendix object to plain java object + OrderData orderData = adapter.toOrderData(this.Order); + + // Process (pure java - no Mendix dependencies) + ProcessResult result = processor.process(orderData, this.SendNotification); + + if (result.isSuccess()) { + // apply result back to Mendix object + adapter.applyResult(this.Order, result); + return true; + } else { + Core.getLogger("MyModule").warn("Order processing failed: " + result.getMessage()); + return false; + } + // end user CODE +} +``` + +**Example - Unit Test (No Mendix Runtime Required):** +```java +// in javasource/mymodule/impl/processorder/OrderProcessorTest.java +package mymodule.impl.processorder; + +import org.junit.Test; +import static org.junit.Assert.*; +import java.math.BigDecimal; + +public class OrderProcessorTest { + + @Test + public void testProcessValidOrder() { + // Arrange - plain java objects, no mocking needed! + OrderProcessor processor = new OrderProcessor(); + OrderData order = new OrderData("ORD-001", new BigDecimal("100.00"), new BigDecimal("21")); + + // Act + ProcessResult result = processor.process(order, false); + + // Assert + assertTrue(result.isSuccess()); + assertEquals(new BigDecimal("21.00"), result.getTaxAmount()); + assertEquals(new BigDecimal("121.00"), result.getFinalAmount()); + } + + @Test + public void testProcessInvalidOrder_MissingOrderNumber() { + OrderProcessor processor = new OrderProcessor(); + OrderData order = new OrderData(null, new BigDecimal("100.00"), new BigDecimal("21")); + + ProcessResult result = processor.process(order, false); + + assertFalse(result.isSuccess()); + assertEquals("Order number is required", result.getMessage()); + } + + @Test + public void testCalculateTax() { + OrderProcessor processor = new OrderProcessor(); + + BigDecimal tax = processor.calculateTax(new BigDecimal("200.00"), new BigDecimal("10")); + + assertEquals(new BigDecimal("20.00"), tax); + } +} +``` + +**Run tests with Maven or standalone:** +```bash +# from javasource directory +javac -cp .:junit-4.13.jar mymodule/impl/processorder/*.java +java -cp .:junit-4.13.jar org.junit.runner.JUnitCore mymodule.impl.processorder.OrderProcessorTest +``` + +**Benefits:** +- **Testable without Mendix** - Run JUnit tests locally or in CI without Mendix runtime +- **Fast feedback** - Unit tests run in milliseconds, not minutes +- **Clear separation** - Business logic is pure Java; Mendix integration is isolated in adapters +- **Reusable** - `OrderProcessor` can be used in other contexts (batch jobs, REST APIs) +- **Maintainable** - Changes to business logic don't require Mendix knowledge + +### Error Handling Best Practices + +1. **Always wrap in try-catch**: +```java +try { + // business logic +} catch (Exception e) { + Core.getLogger("MyModule").error("operation failed", e); + throw new MendixRuntimeException("user-friendly message: " + e.getMessage()); +} +``` + +2. **Validate inputs early**: +```java +if (this.requiredParam == null) { + throw new IllegalArgumentException("RequiredParam is required"); +} +``` + +3. **Use appropriate log levels**: +- `trace`: Detailed debugging +- `debug`: Development information +- `info`: Normal operations +- `warn`: Potential issues +- `error`: Recoverable errors +- `critical`: System failures + +### Performance Considerations + +1. **Batch operations** when possible: +```java +// Instead of committing one by one +list toCommit = new ArrayList<>(); +for (IMendixObject obj : objects) { + obj.setValue(context, "status", "Processed"); + toCommit.add(obj); +} +Core.commit(context, toCommit); // single batch commit +``` + +2. **Use pagination** for large datasets: +```java +int offset = 0; +int batchSize = 1000; +list batch; +do { + batch = Core.createXPathQuery(xpath).setAmount(batchSize).setOffset(offset).execute(context); + // Process batch + offset += batchSize; +} while (batch.size() == batchSize); +``` + +3. **Cache expensive lookups**: +```java +private static map cache = new ConcurrentHashMap<>(); +``` diff --git a/.claude/skills/mendix/java-actions/reference/writing-java.md b/.claude/skills/mendix/java-actions/reference/writing-java.md new file mode 100644 index 000000000..a10d92944 --- /dev/null +++ b/.claude/skills/mendix/java-actions/reference/writing-java.md @@ -0,0 +1,376 @@ +# Writing the Java side + +Supporting reference for [java-actions](../SKILL.md). + +## Part 1: Creating Java Actions in Studio Pro + +### Step 1: Create the Java Action + +In Studio Pro: +1. Right-click module in Project Explorer → **Add other** → **Java action** +2. Name using convention: `JA_ActionName` (e.g., `JA_CalculateTax`, `JA_SendEmail`) +3. Define parameters and return type + +### Step 2: Define Parameters + +| Parameter Type | Mendix Type | Java Type | +|----------------|-------------|-----------| +| String | String | `java.lang.String` | +| Integer | Integer/Long | `java.lang.Long` | +| Decimal | Decimal | `java.math.BigDecimal` | +| Boolean | Boolean | `java.lang.Boolean` | +| DateTime | Date and time | `java.util.Date` | +| Object | Entity | `IMendixObject` | +| List | List of Entity | `java.util.List` | +| StringTemplate(Sql) | SQL template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | +| StringTemplate(Text) | Text template | `com.mendix.core.objectmanagement.member.MendixObjectReference` | + +**Note:** `stringtemplate(sql)` and `stringtemplate(text)` are specialized types for parameterized SQL/OQL queries and text templates respectively. + +### Step 3: Export for Eclipse + +1. Menu → **App** → **Deploy for Eclipse** +2. Open project in Eclipse +3. Find Java action in `javasource//actions/` +## Part 2: Writing Java Action Code + +### Basic Structure + +```java +package mymodule.actions; + +import com.mendix.systemwideinterfaces.core.IContext; +import com.mendix.webui.CustomJavaAction; +import com.mendix.core.Core; +import com.mendix.systemwideinterfaces.core.IMendixObject; + +public class JA_CalculateTax extends CustomJavaAction +{ + private java.math.BigDecimal amount; + private java.math.BigDecimal taxRate; + + public JA_CalculateTax(IContext context, java.math.BigDecimal amount, java.math.BigDecimal taxRate) + { + super(context); + this.amount = amount; + this.taxRate = taxRate; + } + + @java.lang.Override + public java.math.BigDecimal executeAction() throws Exception + { + // begin user CODE + if (this.amount == null || this.taxRate == null) { + return java.math.BigDecimal.ZERO; + } + + return this.amount.multiply(this.taxRate); + // end user CODE + } +} +``` + +**CRITICAL**: Only code between `// begin user CODE` and `// end user CODE` is preserved. Everything else is regenerated by Studio Pro. + +### Working with Mendix Objects + +```java +@java.lang.Override +public IMendixObject executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + // create a new object + IMendixObject order = Core.instantiate(context, "Sales.Order"); + order.setValue(context, "OrderNumber", "ORD-" + System.currentTimeMillis()); + order.setValue(context, "OrderDate", new java.util.Date()); + order.setValue(context, "status", "Draft"); + order.setValue(context, "TotalAmount", java.math.BigDecimal.ZERO); + + // commit to database + Core.commit(context, order); + + return order; + // end user CODE +} +``` + +### Core API Reference + +| Method | Description | +|--------|-------------| +| `Core.instantiate(context, "Module.Entity")` | Create new object | +| `Core.commit(context, object)` | Save to database | +| `Core.commitWithoutEvents(context, object)` | Save without triggering events | +| `Core.delete(context, object)` | Delete object | +| `Core.rollback(context, object)` | Discard uncommitted changes | +| `Core.retrieveId(context, id)` | Retrieve by GUID | +| `Core.createXPathQuery(xpath).execute(context)` | Query with XPath | +| `Core.microflowCall(name).execute(context)` | Call microflow | + +### Reading and Writing Attributes + +```java +// Reading values +string name = (string) order.getValue(context, "Name"); +java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); +boolean isActive = (boolean) order.getValue(context, "IsActive"); +java.util.Date orderDate = (java.util.Date) order.getValue(context, "OrderDate"); + +// Writing values +order.setValue(context, "Name", "New Order"); +order.setValue(context, "Amount", new java.math.BigDecimal("100.00")); +order.setValue(context, "IsActive", true); +order.setValue(context, "ProcessedDate", new java.util.Date()); +``` + +### Working with Associations + +```java +// set association (reference) +IMendixObject customer = Core.createXPathQuery("//Sales.Customer[CustomerCode = 'CUST001']") + .execute(context).get(0); +order.setValue(context, "Sales.Order_Customer", customer.getId()); + +// get associated object +IMendixIdentifier customerId = order.getValue(context, "Sales.Order_Customer"); +if (customerId != null) { + IMendixObject relatedCustomer = Core.retrieveId(context, customerId); + string customerName = (string) relatedCustomer.getValue(context, "Name"); +} +``` + +### Working with Lists + +```java +// retrieve list +list orders = Core.createXPathQuery("//Sales.Order[status = 'Pending']") + .execute(context); + +// Process list +java.math.BigDecimal total = java.math.BigDecimal.ZERO; +for (IMendixObject order : orders) { + java.math.BigDecimal amount = (java.math.BigDecimal) order.getValue(context, "Amount"); + if (amount != null) { + total = total.add(amount); + } +} + +// create list to return +list results = new java.util.ArrayList<>(); +results.add(order1); +results.add(order2); +return results; +``` + +### Error Handling + +```java +@java.lang.Override +public boolean executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + try { + // business logic here + IMendixObject order = Core.instantiate(context, "Sales.Order"); + order.setValue(context, "OrderNumber", generateOrderNumber()); + Core.commit(context, order); + return true; + + } catch (Exception e) { + // log error + Core.getLogger("MyModule").error("Failed to create order: " + e.getMessage(), e); + + // Optionally throw to show error to user + throw new com.mendix.systemwideinterfaces.MendixRuntimeException( + "Could not create order: " + e.getMessage()); + } + // end user CODE +} +``` + +### Logging + +```java +import com.mendix.logging.ILogNode; + +// get logger +ILogNode logger = Core.getLogger("MyModule.MyAction"); + +// log at different levels +logger.trace("Detailed trace message"); +logger.debug("debug information"); +logger.info("Processing started for order: " + orderNumber); +logger.warn("Unusual condition detected"); +logger.error("error processing order", exception); +logger.critical("critical system failure"); +``` +## Part 4: Common Java Action Patterns + +### Pattern 1: Validation Helper + +**Java Action Definition:** +- Name: `JA_ValidateEmail` +- Parameter: `EmailAddress` (String) +- Return: Boolean + +```java +@java.lang.Override +public java.lang.Boolean executeAction() throws Exception +{ + // begin user CODE + if (this.EmailAddress == null || this.EmailAddress.trim().isEmpty()) { + return false; + } + + string emailRegex = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$"; + return this.EmailAddress.matches(emailRegex); + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Customer.VAL_CustomerEmail($customer: Customer.Customer) +returns boolean as $isValid +begin + $isValid = call java action Customer.JA_ValidateEmail( + EmailAddress = $customer/Email + ); + + if not($isValid) then + validation feedback $customer/Email + message 'Please enter a valid email address'; + end if; + + return $isValid; +end; +``` + +### Pattern 2: External API Call + +**Java Action Definition:** +- Name: `JA_FetchExchangeRate` +- Parameters: `FromCurrency` (String), `ToCurrency` (String) +- Return: Decimal + +```java +@java.lang.Override +public java.math.BigDecimal executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + try { + // build api url + string url = "https://api.exchangerate.host/convert?from=" + + this.FromCurrency + "&to=" + this.ToCurrency; + + // Make HTTP request (using your preferred HTTP client) + java.net.HttpURLConnection conn = + (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); + conn.setRequestMethod("get"); + + // Parse response + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(conn.getInputStream())); + StringBuilder response = new StringBuilder(); + string line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + // Parse json and extract rate (simplified) + // in production, use a proper json library + string json = response.toString(); + int rateIndex = json.indexOf("\"result\":"); + if (rateIndex > 0) { + string rateStr = json.substring(rateIndex + 9, json.indexOf(",", rateIndex)); + return new java.math.BigDecimal(rateStr.trim()); + } + + return java.math.BigDecimal.ONE; + + } catch (Exception e) { + Core.getLogger("ExchangeRate").error("Failed to fetch rate", e); + throw new com.mendix.systemwideinterfaces.MendixRuntimeException( + "Could not fetch exchange rate: " + e.getMessage()); + } + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Finance.ACT_ConvertCurrency( + $amount: decimal, + $fromCurrency: string, + $toCurrency: string +) +returns decimal as $convertedAmount +begin + declare $rate decimal; + + $rate = call java action Finance.JA_FetchExchangeRate( + FromCurrency = $fromCurrency, + ToCurrency = $toCurrency + ); + + set $convertedAmount = $amount * $rate; + return $convertedAmount; +end; +``` + +### Pattern 3: File Processing + +**Java Action Definition:** +- Name: `JA_GeneratePDF` +- Parameters: `Order` (Sales.Order entity), `TemplateName` (String) +- Return: System.FileDocument + +```java +@java.lang.Override +public IMendixObject executeAction() throws Exception +{ + // begin user CODE + IContext context = getContext(); + + // get order data + string orderNumber = (string) this.Order.getValue(context, "OrderNumber"); + java.math.BigDecimal total = (java.math.BigDecimal) this.Order.getValue(context, "TotalAmount"); + + // generate PDF content (using iText or similar library) + byte[] pdfContent = generatePdfBytes(orderNumber, total); + + // create FileDocument + IMendixObject fileDoc = Core.instantiate(context, "System.FileDocument"); + fileDoc.setValue(context, "Name", "Order_" + orderNumber + ".pdf"); + Core.storeFileDocumentContent(context, fileDoc, + new java.io.ByteArrayInputStream(pdfContent)); + + Core.commit(context, fileDoc); + + return fileDoc; + // end user CODE +} +``` + +**MDL Usage:** +```mdl +create microflow Sales.ACT_GenerateOrderPDF($order: Sales.Order) +returns System.FileDocument as $pdfFile +begin + $pdfFile = call java action Sales.JA_GeneratePDF( + Order = $order, + TemplateName = 'OrderConfirmation' + ); + + log info node 'Sales' 'Generated PDF for order: ' + $order/OrderNumber; + + return $pdfFile; +end; +``` diff --git a/.claude/skills/mendix/odata-data-sharing/SKILL.md b/.claude/skills/mendix/odata-data-sharing/SKILL.md index 006ff9b75..c1b5a7a88 100644 --- a/.claude/skills/mendix/odata-data-sharing/SKILL.md +++ b/.claude/skills/mendix/odata-data-sharing/SKILL.md @@ -7,6 +7,19 @@ description: "Share data between Mendix apps over OData — published services, This skill covers how to use OData services to share data between Mendix applications, with emphasis on using view entities as an abstraction layer to decouple the API contract from the internal domain model. +## Reference files + +`SKILL.md` covers the architecture, the contract rules, and the conventions. The +long build-outs are next door: + +- [`reference/walkthroughs.md`](reference/walkthroughs.md) — four complete + builds, start to finish: a read-only API behind view entities, a + non-persistable published entity, a read-write API with microflow handlers, + publishing a microflow as an OData action, and the GraphQL variant. +- [`reference/errors-and-auth.md`](reference/errors-and-auth.md) — which HTTP + status each capability can actually return, the authentication methods and what + Basic auth costs, and the configuration microflow for custom headers. + ## When to Use This Skill - User asks to expose data from one Mendix app to another @@ -145,1084 +158,6 @@ Publishing persistent entities directly exposes your internal schema. When you c 4. **Filtered datasets** -- restrict what's visible (e.g., only active products, cheap products) 5. **Aggregations** -- expose pre-aggregated metrics (e.g., orders per day, sum of line items) -## Step-by-Step: Read-Only API with View Abstraction - -### Step 1: Create the Producer Module and Role - -```sql -create module ProductApi; - -create module role ProductApi.ApiUser - description 'Role for OData API access'; -``` - -### Step 2: Create View Entities as the API Layer - -Instead of publishing `Shop.Product` and `Shop.Price` directly, create a view that joins and flattens them: - -```sql -/** - * Flattened product with current active price. - * Joins Product with the most recent Price entry. - */ -create view entity ProductApi.ProductWithPriceVE ( - ProductId: integer, - Name: string, - description: string, - PriceInEuro: decimal -) as ( - select p.ID as ProdId - , p.ProductId as ProductId - , p.Name as Name - , p.Description as description - , ( select pr.PriceInEuro - from Shop.Price as pr - where pr.StartDate <= '[%BeginOfTomorrow%]' - and pr/Shop.Price_Product = p.ID - order by pr.StartDate desc - limit 1 - ) as PriceInEuro - from Shop.Product as p - where p.IsActive -); - -grant ProductApi.ApiUser on ProductApi.ProductWithPriceVE - (read *, write *); -``` - -For aggregated data: - -```sql -/** - * Daily sales totals for cheap products. - */ -create view entity ProductApi.CheapProductSalesVE ( - OrderDate: datetime, - TotalItems: long -) as ( - select o.OrderDate as OrderDate - , sum(ol.Amount) as TotalItems - from Shop.OrderLine as ol - left join Shop.OrderLine_Order/Shop."Order" as o - where ol/Shop.OrderLine_Product/Shop.Product.PriceInEuro < 100 - group by o.OrderDate - order by o.OrderDate desc - limit 1000 -); - -grant ProductApi.ApiUser on ProductApi.CheapProductSalesVE - (read *, write *); -``` - -For flattening across associations: - -```sql -/** - * Customer with billing and delivery address flattened into one resource. - */ -create view entity ProductApi.CustomerAddressVE ( - CustomerId: long, - CustomerName: string, - Email: string, - BillingStreet: string, - BillingCity: string, - BillingCountry: string, - DeliveryStreet: string, - DeliveryCity: string, - DeliveryCountry: string -) as ( - select c.ID as CustomerID - , c.CustomerId as CustomerId - , c.FirstName + ' ' + c.LastName as CustomerName - , c.EmailAddress as Email - , ba.Streetname as BillingStreet - , ba.City as BillingCity - , ba.Country as BillingCountry - , da.Streetname as DeliveryStreet - , da.City as DeliveryCity - , da.Country as DeliveryCountry - from Shop.Customer as c - left outer join c/Shop.BillingAddress_Customer/Shop.Address as ba - left outer join c/Shop.DeliveryAddress_Customer/Shop.Address as da -); - -grant ProductApi.ApiUser on ProductApi.CustomerAddressVE - (read *, write *); -``` - -### Step 3: Publish the OData Service - -```sql -/** - * Product and customer data API. - * Exposes flattened views for external consumers. - */ -create odata service ProductApi.ProductDataApi ( - path: 'odata/productdataapi/v1/', - version: '1.0.0', - ODataVersion: OData4, - namespace: 'DefaultNamespace', - ServiceName: 'ProductDataApi', - Summary: 'Product and customer data API' - -- PublishAssociations is left at its default (Yes = associations as links). - -- Setting it to No means "associations as an associated object id", which - -- Mendix only allows when the system ID is published as the key — publishing - -- an ordinary attribute as the key then fails the build with CE7375, even - -- when no associations are exposed at all. -) -authentication basic -{ - publish entity ProductApi.ProductWithPriceVE as 'Product' ( - ReadMode: ReadFromDatabase, - InsertMode: NotSupported, - UpdateMode: NotSupported, - DeleteMode: NotSupported - ) - expose ( - ProductId as 'ProductId' (Filterable, Sortable, key), - Name as 'Name' (Filterable, Sortable), - description as 'Description' (Filterable, Sortable), - PriceInEuro as 'PriceInEuro' (Filterable, Sortable) - ); - - publish entity ProductApi.CustomerAddressVE as 'CustomerAddress' ( - ReadMode: ReadFromDatabase, - InsertMode: NotSupported, - UpdateMode: NotSupported, - DeleteMode: NotSupported - ) - expose ( - CustomerId as 'CustomerId' (Filterable, Sortable, key), - CustomerName as 'CustomerName' (Filterable, Sortable), - Email as 'Email' (Filterable, Sortable), - BillingStreet as 'BillingStreet' (Filterable, Sortable), - BillingCity as 'BillingCity' (Filterable, Sortable), - BillingCountry as 'BillingCountry' (Filterable, Sortable), - DeliveryStreet as 'DeliveryStreet' (Filterable, Sortable), - DeliveryCity as 'DeliveryCity' (Filterable, Sortable), - DeliveryCountry as 'DeliveryCountry' (Filterable, Sortable) - ); -}; - -grant access on odata service ProductApi.ProductDataApi - to ProductApi.ApiUser; -``` - -### Step 4: Set Up the Consumer App - -In the consuming application, create an OData client and external entities: - -```sql -create module ProductClient; - -create module role ProductClient.User; - --- Location constant (configure per environment) -create constant ProductClient.ProductDataApiLocation - type string - default 'http://localhost:8080/odata/productdataapi/v1/'; - --- OData client connection -create odata client ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'http://localhost:8080/odata/productdataapi/v1/$metadata', - timeout: 300, - ServiceUrl: '@ProductClient.ProductDataApiLocation', - UseAuthentication: Yes, - HttpUsername: 'MxAdmin', - HttpPassword: '1' -); - --- OData client with local file - relative path (offline development) --- Resolved relative to .mpr directory when project is loaded -CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: './metadata/productdataapi.xml', - Timeout: 300, - ServiceUrl: '@ProductClient.ProductDataApiLocation', - UseAuthentication: Yes, - HttpUsername: 'MxAdmin', - HttpPassword: '1' -); - --- OData client with local file - relative path without ./ -CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'metadata/productdataapi.xml', - Timeout: 300, - ServiceUrl: '@ProductClient.ProductDataApiLocation', - UseAuthentication: Yes, - HttpUsername: 'MxAdmin', - HttpPassword: '1' -); - --- OData client with local file - absolute file:// URI -CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( - ODataVersion: OData4, - MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml', - Timeout: 300, - ServiceUrl: '@ProductClient.ProductDataApiLocation', - UseAuthentication: Yes, - HttpUsername: 'MxAdmin', - HttpPassword: '1' -); - --- External entities (mapped from published service) -create external entity ProductClient.ProductsEE -from odata client ProductClient.ProductDataApiClient -( - EntitySet: 'Product', - RemoteName: 'Product', - Countable: Yes -) -( - ProductId: long, - Name: string, - description: string, - PriceInEuro: decimal -); - -grant ProductClient.User on ProductClient.ProductsEE (read *); - -create external entity ProductClient.CustomerAddressesEE -from odata client ProductClient.ProductDataApiClient -( - EntitySet: 'CustomerAddress', - RemoteName: 'CustomerAddress', - Countable: Yes -) -( - CustomerId: long, - CustomerName: string, - Email: string, - BillingStreet: string, - BillingCity: string, - BillingCountry: string, - DeliveryStreet: string, - DeliveryCity: string, - DeliveryCountry: string -); - -grant ProductClient.User on ProductClient.CustomerAddressesEE (read *); -``` - -**Bulk alternative:** Instead of creating external entities one by one, import all (or a subset) from the contract: - -```sql --- All entities from the service -create external entities from ProductClient.ProductDataApiClient; - --- Or specific ones only -create external entities from ProductClient.ProductDataApiClient - entities (Product, CustomerAddress); - --- Idempotent re-import -create or modify external entities from ProductClient.ProductDataApiClient; -``` - -### AllowCreateChangeLocally — Read-Only API, Editable in the App - -Use `AllowCreateChangeLocally: Yes` when the remote OData API only supports GET (read-only), but the Mendix app needs to let users edit the data locally before passing it to another API call — for example, an external action or a REST microflow that POSTs the change to a different endpoint. - -Without this flag, external entities are completely non-editable in the client: form widgets are read-only and no in-memory change can be committed. With the flag, Mendix allows the object to be created and changed locally (in memory or in the Mendix database), without trying to write back through the OData client. - -**Typical pattern:** -1. Retrieve data via the OData client into the external entity (GET only). -2. User edits the record in a page — possible because `AllowCreateChangeLocally` is set. -3. A microflow reads the changed object and calls an external action or REST operation (POST/PUT) to submit the change to the remote system. - -```sql --- API is read-only (no insert/update/delete on the OData endpoint). --- AllowCreateChangeLocally lets users edit the object in the app --- and submit changes via a separate external action. -create or modify external entity ShopClient.Product -from odata client ShopClient.ShopApiClient -( - EntitySet: 'Products', - Countable: Yes, - Creatable: No, - Deletable: No, - Updatable: No, - AllowCreateChangeLocally: Yes -) -( - ProductId: long, - Name: string, - Price: decimal -); - --- Toggle the flag without recreating the entity. -alter entity ShopClient.Product set allow_create_change_locally = true; -alter entity ShopClient.Product set allow_create_change_locally = false; -``` - -## Publishing a Non-Persistable Entity (no copy of the data) - -A published entity does **not** have to be persistable. Back it with a read -microflow and the rows are produced per request — nothing is stored, and there -is no refresh job to keep a copy in step with the source. This is the shape to -use when the data lives outside Mendix (an external database, a CSV, an API). - -```sql -create non-persistent entity Api.Lap ( - LapKey: string(60), - Driver: string(120), - LapTime: decimal -); - --- While Countable is Yes (the default), the read microflow MUST take a --- $Response: System.ODataResponse parameter — Mendix asks it for the count. -CREATE MICROFLOW Api.Read_Laps ($Response: System.ODataResponse) - RETURNS List of Api.Lap AS $Laps -BEGIN - -- retrieve from wherever the data actually lives, e.g. EXECUTE DATABASE QUERY - $Laps = CREATE LIST OF Api.Lap; - RETURN $Laps; -END; - -create odata service Api.LapApi ( - path: 'odata/laps/', - version: '1.0.0', - ODataVersion: OData4, - namespace: 'Api.Laps' -) -authentication basic -{ - publish entity Api.Lap as 'Laps' ( - ReadMode: microflow Api.Read_Laps, - InsertMode: not_supported, - UpdateMode: not_supported, - DeleteMode: not_supported - ) - expose ( - LapKey as 'lapKey' (KEY, Filterable, Sortable), - Driver (Filterable, Sortable), - LapTime (Sortable) - ); -}; -``` - -Two things worth knowing before you write this: - -- **`ReadMode: microflow Module.MF`** is the whole feature. `InsertMode`, - `UpdateMode` and `DeleteMode` take the same form for a read-write resource. -- **Counting is not free.** If the count means a full scan of the underlying - source, set `Countable: No` on the published entity — the read microflow then - takes no parameters at all. `SkipSupported: No` and `TopSupported: No` turn - off `$skip` and `$top` the same way. All three default to Yes. - -`PublishAssociations` must stay at its default (Yes) — and not only here. - -**It is not a yes/no, it is a two-value representation.** Studio Pro's own labels -for it are "As a link (recommended)" (Yes) and "As an associated object id" (No). -So `PublishAssociations: No` does not mean "this service publishes no -associations"; it selects the legacy representation, which requires the system -`ID` attribute published as the key. MDL cannot publish the system ID (CE1613), -so `No` cannot build from a script. - -That holds even when the service publishes no associations at all, and even for -a persistable entity with a perfectly good key of its own. Measured on Mendix -11.13, both arms of the same service: - -| | `mx check` | -|---|---| -| `PublishAssociations: No` | **CE7375** "Attribute ID … must be published and be the key when associations are exposed as an associated object id" | -| `PublishAssociations: Yes` | 0 errors | - -The error names a concept the script never mentions, which is why this costs -hours rather than minutes. `mxcli check` now warns (MDL-ODATA06). - -**Do not take CE7375's advice literally.** It says to publish the `ID` and make -it the key, and that is the wrong direction for anything you share outside the -app. The two representations exist for a reason: OData v3 had no link support, -so a foreign key had to be an exposed object id; v4 added links largely so -internal ids no longer had to leave the app. Going back to ids gives up that. - -**A published key should be a business key.** Mendix object ids are -autogenerated and are not stable across an app landscape — the same record has -different ids in test, acceptance and production — so an id baked into an -external contract breaks the moment a consumer moves between environments, or -compares data from two of them. Pick something the business already guarantees: -an invoice number, an ISIN, an employee number. Mendix requires a key to be -unique, required and stable (the last is the point here), and the unique -validation rule it makes you add is checking exactly that. - -That is also why the key needs `unique error '…'` on the attribute — see the -CE6624 note below. Both halves of the same idea: the value identifies one row, -and keeps identifying it. - -### A view entity read from the database gets the query options for free - -**This decides whether you need any pushdown machinery at all**, so check it -before reaching for Java. - -A published resource has an `Action`: *Read from database*, or a read microflow. -The difference is not a detail: - -| Action | `$filter` `$orderby` `$top` `$skip` `$count` | -|---|---| -| **Read from database** (a view entity, or a persistable one) | **Mendix applies them** — they reach the database | -| Read microflow | Mendix applies **none** of them; whatever the microflow returns is what the client gets | - -Measured on 11.13 against a running app — an OQL view over four rows aggregating -to three, published with `Action: Read from database` and no Java anywhere: - -``` -$top=1 -> 1 row, not 3 -$count=true&$top=1 -> "@odata.count": 3, one row returned -$filter=Category eq 'Rent' -> only the Rent row -$orderby=Total desc&$skip=1 -> [400, 250] (1500 correctly skipped) -``` - -So for a **view entity**, aggregation happens in the database and paging and -filtering push down to it — a chart or grid can page a large resource with -nothing hand-written. That is the whole capability the `mendix-odata-pushdown` -pack exists to recreate. - -**Which options a read microflow actually has to implement — measured, and not -all-or-nothing.** The same view served both ways on 11.13, three rows behind -each: - -| option | database read | read microflow | -|---|---|---| -| `$select` | applied | **applied** — Mendix projects the response either way | -| `$filter` | applied | **200, unfiltered** | -| `$orderby` | applied | **200, unsorted** | -| `$top` / `$skip` | applied | **200, full set** | -| `$count` | applied | needs `System.ODataResponse` (CE6962) | - -Two things follow that "Mendix applies none of them" gets wrong: - -- **`$select` is not the microflow's correctness problem.** The client already - receives only the fields it asked for. And the consumer drives it: removing - attributes from an external entity narrows the `$select` it sends, because the - external entity has nowhere to put what it dropped. So pushing `$select` into - the source query is a *cost* optimisation — fewer columns read at the source — - never a fix for wrong output. -- **Declaring the capability is what turns a safe refusal into a silent lie.** - With `Filterable`/`Sortable` *not* declared, Mendix rejects the request: - `400 "Property 'Category' is non-filterable."` Declare them — which you must, - or no client can filter at all — and the identical request becomes 200 with - every row. The declaration is a promise Mendix enforces at the boundary and - does not keep for you. - -That second one is the sharpest statement of why this work exists: the failure -is *created by* promising the capability, and the microflow is the only place -left to keep the promise. - -The pack is for the case a view cannot cover: **the data is not in this app's -database at all**, so there is no table for a view to select from and a read -microflow is the only way to produce the rows. Mendix then applies nothing to -them — a `?$top=5` that quietly returns all 917 rows. - -Its motivating shape is two apps, and the topology is what makes the pushdown -load-bearing rather than an optimisation: - -``` -frontend app --- external entities / OData ---> backend app -(grid, chart) (no data of its own) - | - external database connector - | - DuckDB over CSV -``` - -The frontend's grid pages and filters by generating `$top` / `$skip` / -`$filter` — it has no other vocabulary, because external entities *are* OData. -The backend's read microflow has to translate those options into the SQL it -sends through the connector. Without that translation the frontend's paging -still looks correct while every page drags the whole file across, and nothing -in either app reports a problem. - -Two consequences worth holding on to: - -- **A view entity is not an option here**, so "prefer the view" is not advice - that applies. The question is only whether *this app* owns the data. -- **The consumer's capability flags must match the service.** An external - entity generated with `TopSupported`/`SkipSupported` that the service does - not honour is CE6630 in the consuming app — the two ends of this contract - are checked against each other. - -If the resource *is* backed by this app's own tables, prefer a view entity and -skip the machinery entirely. - -### An aggregate view's key is its grain - -A summary resource — an OQL view entity, or a non-persistable row filled by a -read microflow — has no business key to reach for. Monthly totals per category -are not an invoice; nothing in the domain issues them a number. - -**The key is the grain: the columns the aggregate groups by.** For monthly -totals per category that is `(Period, Category)` — together they identify -exactly one row, they are stable because they are the definition of the row, and -they mean the same thing in every environment. - -What not to do is cast the internal id into a column (`cast(c.id as string) as -RowId`) and publish that. It satisfies "a key" and it is the id problem again, -one level down: autogenerated, environment-specific, and now stable only as long -as nobody rebuilds the view. - -Measured on Mendix 11.13, each row a separate build: - -| shape | result | -|---|---| -| single key attribute, persistable, no `unique` rule | **CE6624** — add one | -| single key attribute, persistable, `unique error '…'` | 0 errors | -| **single key attribute, VIEW entity, no `unique` rule** | **0 errors** | -| **composite key, `OData3`** | **CE7238** "You can only have more than one key attribute when the OData version is 4" | -| composite key, `OData4`, persistable, no `unique` rules | 0 errors | -| **composite key, `OData4`, non-persistable, no `unique` rules** | **0 errors** | -| any validation rule on a non-persistable entity | **CE0070** — not allowed | - -Two consequences worth holding on to: - -- **A grain key needs `ODataVersion: OData4`.** More than one key attribute is a - v4 feature; on v3 the same model is CE7238. -- **A composite key needs no `unique` validation rule**, and a non-persistable - entity could not carry one anyway (CE0070). The rule is only demanded for a - *single*-attribute key, where one attribute has to be unique by itself — - which is exactly the case a grain is not. So the CE6624 hurdle disappears - the moment the key is honest about being multi-column. -- **CE6624 does not apply to a view entity at all.** A view can carry a - *single*-attribute key with no validation rule and build cleanly — confirmed - against a Studio Pro service publishing a view keyed on one column. So if the - view already has a naturally unique column (an id carried through from the - source data, not the platform's object id), key on that and skip the grain. - Reach for the grain when no single column identifies a row — which is the - normal case for an aggregate. - -```sql -create non-persistent entity Fin.VMonthCategory ( - Period: string(7), -- 2026-08 - Category: string(60), - Total: decimal -); - -create odata service Fin.ChartApi ( - path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts', - version: '1.0.0', ODataVersion: OData4 -- required: the key is composite -) { - publish entity Fin.VMonthCategory ( - ReadMode: microflow Fin.Read_MonthCategory, - Countable: No -- else CE6962 wants System.ODataResponse - ) - expose ( Period (KEY), Category (KEY), Total ) -} -``` - -(Measured on 11.13: `PublishAssociations: Yes` builds under both `OData3` and -`OData4`, so choosing links is not a v4-only option in current Mendix.) - -**`Path` has two rules and one trap.** No leading slash (CE6550), and it must end -with a single slash (CE6552). A path with **no slash at all** is the trap: mxbuild -throws `System.ArgumentOutOfRangeException` out of its own validator, with no -error code, no element name and no line, which reads as a corrupt project. Use -`'odata/thing/'`. `mxcli check` catches all three (MDL-ODATA05). - -## Also Publishing as GraphQL - -`SupportsGraphQL: Yes` makes the same service answer GraphQL as well as OData. -One boolean; the OData surface is untouched. - -```sql -create odata service Fin.ChartApi ( - path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts', - version: '1.0.0', ODataVersion: OData4, - SupportsGraphQL: Yes -) { ... } -``` - -**The GraphQL endpoint is the service location itself** — there is no `/graphql` -path. Clients `POST` a query to the same URL that serves OData: - -``` -GET /odata/charts/$metadata -> the OData contract -POST /odata/charts/ -> {"query":"{ monthCategories { period } }"} -``` - -Verified against a running Mendix 11.13 app: - -| request | response | -|---|---| -| `POST` `{ __schema { queryType { name } } }` | `{"data":{"__schema":{"queryType":{"name":"Query"}}}}` | -| `POST` `{ monthCategories { period category total } }` | `{"data":{"monthCategories":[]}}` | - -Three things that only bite once GraphQL is on: - -- **Query field names are camelCased.** `Period` in the model is `period` in a - query; asking for `Total` returns 400 - `{"errors":[{"message":"Field 'Total' not found"}]}`. The OData names are - unchanged, so the two surfaces spell the same attribute differently. -- **Exposed names must be unique beyond case (CE2881).** Publishing an entity - without `as '...'` gives the entity type and the entity set the same name, - which OData accepts and GraphQL rejects. A service that built yesterday can - fail on the day it is enabled. Give the set its own name: - `publish entity Fin.VMonthCategory as 'MonthCategories'`. -- **`PublishAssociations` must be Yes.** GraphQL has no representation for an - associated object id, so Mendix refuses the pair: CE8055 "A service that - supports GraphQL must publish associations as a link." mxcli refuses it before - writing, since no other change can make it build. -- **Mendix 10.14+**, where it arrived as an experimental feature. mxcli refuses - the statement on an older project rather than writing a property that version's - metamodel does not have — an unknown property is not a build error, it is a - document Studio Pro will not open. - -### What GraphQL actually covers, measured - -The GraphQL surface is narrower than the OData one, and the gaps are not -documented next to the checkbox. Introspected and exercised on 11.13, on a -resource published over both at once: - -| OData | GraphQL | -|---|---| -| `$select` | **inherent** — you name the fields, that *is* the projection | -| `$top` | `first: Int` | -| `$skip` | `offset: Int` | -| `$orderby` | `orderBy: [{field: ASC\|DESC}]` | -| key lookup `Set(K='v')` | a singular field: `vMonthCategory(period: "…", category: "…")` | -| **`$filter`** | **absent** | -| **`$count`** | **absent** | -| `$expand` | not measured here (the probe has no associations) | - -The whole schema for a one-entity service is nine types — `Query`, -`SortOrder`, the entity, its order input, and the scalars. There is no filter -type, no where type and no count type in it. - -Two traps, both measured: - -- **`orderBy` must be a LIST.** `orderBy: {total: DESC}` fails with - `Incorrect value for orderBy`, while `orderBy: [{total: DESC}]` works — and - introspection advertises the argument as a bare input object - (`VMonthCategoryOrderInput`), not a list, so the schema and the parser - disagree. The error does not mention it. -- **An unknown argument is silently ignored.** `monthCategories(where: {…})` - and even `monthCategories(bogusArgument: 42)` both return **200 with the - full result set** rather than an error. A client that assumes a filter - argument exists gets every row and no warning — the same "200 with the wrong - rows" failure the pushdown pack was written about, in a different surface. - -So: **paging and sorting are safe over GraphQL; filtering is not there.** A -widget that needs server-side filtering has to use the OData surface, and a -resource where the client filters is a reason to keep OData even when GraphQL -is enabled. - -GraphQL here is not as complete as the OData surface — it is a second way to read -the same published resources, which some widgets and clients prefer. - -## HTTP Status Codes and Errors: What Each Capability Can Do - -**The read path and the write path have different powers, and the difference is -the single most expensive thing to get wrong here.** Read this before designing -any microflow-backed resource. - -| Capability | Can set the HTTP status code? | How | -|---|---|---| -| OData **action** (published microflow) | **Yes** | add a `System.HttpResponse` parameter | -| Entity **Insertable / Updatable / Deletable** microflow | **Yes** | add a `System.HttpResponse` parameter | -| Entity **Readable** microflow | **No** | not offered — the read capability has no documented `HttpResponse` parameter | - -Sources: [published-odata-microflow §4](https://docs.mendix.com/refguide/published-odata-microflow/#4-customizing-the-outgoing-http-response), -[published-odata-entity, custom HTTP response](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response). -The custom-response section names Insertable, Updatable and Deletable; the -Readable section never references it. - -### Writing a status code (action / insert / update / delete) - -```sql -create microflow Api.InsertRow ( - $Row: Api.Row, - $HttpResponse: System.HttpResponse -) -begin - if $Row/RowKey = empty then - change $HttpResponse (StatusCode = 400, Content = '{"error":"rowKey is required"}'); - return; - end if; - ... -end; -``` - -Three rules the platform imposes: - -- **`ReasonPhrase` is ignored.** Setting it is dead code; put the explanation in - `Content`. -- **`204` always produces an empty body.** Setting `Content` alongside it is - discarded. -- **Changing status or content makes the whole response come from - `HttpResponse`** — headers included. Changing *only* headers merges them with - the defaults instead. -- `Transfer-Encoding` and `Date` cannot be changed. - -### The read path cannot refuse, so it must not over-promise - -A read microflow has no way to answer `400`. Its only exits are to throw (a -blunt `500`) or to return data. That has two consequences, and both are design -obligations rather than nice-to-haves: - -**1. Declare capabilities you do not implement as `No`.** Mendix applies *no* -query options to a read-microflow resource — it hands over the request and -returns whatever comes back — so `TopSupported` / `SkipSupported` / `Countable` -are claims about your microflow, not about the platform. A resource that -advertises `TopSupported: Yes` and ignores `$top` returns the entire collection -with a `200`, and the client believes it received a page. - -```sql -publish entity Api.Row as 'Rows' ( - ReadMode: microflow Api.Read_Rows, - -- Only claim what Read_Rows actually parses out of the URI: - TopSupported: No, - SkipSupported: No, - Countable: No -) -``` - -Declaring `No` is the read path's substitute for the `400` it cannot send. It is -also safe to declare: mxcli reads these capabilities off the contract, so a -consuming app generated from this service says `No` too. (Until it learned to, -the generator always said `Yes`, and the consumer failed to build with **CE6630** -"'Rows' is marked supports $top=False in the OData service, but True in the app" -— if you hit that against an older mxcli, that is the cause.) - -**The capabilities vocabulary has two annotation shapes**, and it is worth knowing -which you are looking at when reading a `$metadata` by hand: - -| Capability | Shape | -|---|---| -| `TopSupported`, `SkipSupported` | standalone: `` | -| `Insertable`, `Updatable`, `Deletable`, `Countable` | a `` with a `Bool` property value | -| `Filterable`, `Sortable` | **either** — a record listing `NonFilterableProperties` when *some* attributes are filterable, or a bare `Bool="false"` on the record when *none* are | - -That last row is the trap. Mendix picks the shape by arithmetic (there is no list -to write when nothing is filterable), so both appear in one document on different -entity sets, and a service where an entity exposes only a KEY emits the whole-set -form. - -`mxcli check` enforces this as **MDL-ODATA03**, and it looks for the option -*names* in the microflow body, not just for a `System.HttpRequest` parameter — -adding the parameter to answer the KEY (below) does not silence the paging -warning. A microflow that hands the request to a Java action, a JavaScript -action, or a microflow outside the script is left alone, since mxcli cannot read -what those do. - -**2. Answer a lookup by your own KEY.** A client holding a row re-reads it by -key, unprompted, and Mendix's own OData client sends the `$filter` spelling: - -``` -?$filter=rowKey eq '1036-c' ← what the runtime actually sends -/Rows('1036-c') ← bare path key -/Rows(rowKey='1036-c') ← named path key -``` - -If the microflow parses only its collection filter, the key request falls through -to the collection default and the client adopts the **first row** as the identity -of the object it is displaying. There is no error: the request is well-formed, -the response is a valid collection, the count is right, the status is `200`. Two -different objects are then on screen at once, and nothing distinguishes them -until one travels to another page. - -So: `expose ( … (KEY) )` is a promise the *service* makes on the *microflow's* -behalf. Branch key → id → filter → default. - -**Not declaring the KEY is not a way out.** Mendix requires a published entity to -have one — `CE6585 "Published entity 'X' must have a key defined."` — so the only -correct resolution is to answer the lookup. (Query *options* you may decline; -the key you may not.) - -The request itself always arrives on `System.HttpRequest`: - -```sql -create microflow Api.Read_Rows ( - $Request: System.HttpRequest, - $Response: System.ODataResponse -- required while Countable is Yes -) -returns List of Api.Row -begin - log info 'URI=' + $Request/Uri; -- the whole query string, URL-encoded - ... -end; -``` - -To watch what clients actually send, raise the **`OData Publish`** log node (note -the space; it exists only when the project publishes a service): - -```bash -mxcli log set "OData Publish" TRACE -``` -``` -TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc' -DEBUG - OData Publish: Responding to client with status code 400. -``` - -That is the fastest way to see a client re-reading a row by key, and it needs no -change to the model. `ODataConsume` is the client side, a different node. - -Mendix validates field names before the microflow runs (`$filter=secretColumn eq 'x'` -is a `400` from the platform), and it enforces `Filterable`: filtering on a property -you did not declare filterable is rejected with `400 "Property 'x' is -non-filterable"` before the microflow runs. So the microflow only ever sees names -that exist in the published metadata. That is defence in depth, not a substitute for a -whitelist — it constrains the *name*, not what you do with it. - -## OData Actions: Publishing a Microflow - -An entity set is a *read* surface. To let a client **invoke** something — -`POST /odata/x/RecordNote` with arguments in the body — publish a microflow. -Mendix exposes it in `$metadata` as an `ActionImport`. - -```sql -create odata service ProductApi.Actions ( ... ) -authentication basic -{ - publish microflow ProductApi.RecordNote as 'RecordNote' - expose ( Note as 'note', Amount as 'amount' (CanBeEmpty) ); -}; -``` - -**Parameter data types and the return type are not written in MDL.** They are -read off the microflow, which already declares them — the same thing Studio Pro -does, and the only arrangement in which the two cannot drift. Omitting the -`expose` clause publishes every parameter under its own name. - -### Why this matters more than it looks - -Mendix validates `$filter` against the published metadata **before** the read -microflow runs. So a parameterised *entity set* cannot take its arguments as -filters: `?$filter=driverId eq 'x'` against a resource whose entity has no -`driverId` attribute is answered - -``` -400 Could not map 'driverId' to attribute or association. -``` - -and the microflow never sees it. The workaround is to carry every parameter as -an attribute and echo it back on each row — `SELECT f.*, {driverId} AS driver_id -…`. An action takes them as parameters and needs none of that. - -### Two things Mendix will not do for you - -- **A returning stored procedure cannot be called directly.** The External - Database Connector dispatches a `CALL` as an update, and PgJDBC refuses - because a `CALL` with INOUT parameters answers with a row: *"A result was - returned when none was expected"*. There is no `execute database statement` - activity — only `execute database query`, which wants a `SELECT`. Wrap the - procedure in a one-line function that `CALL`s it and returns its row. -- **The JDBC driver must be declared and shipped even for PostgreSQL**, the - database Mendix itself runs on. Without a module jar dependency the build - fails CE5278; declared but `included = false`, the build is green and the - first request dies with *"No JDBC driver found in app for URL"* — the - Connector resolves drivers from the app's classpath, not the runtime's. Use - `included = true` and `mxcli sync-java-deps`. - -## Authentication Methods, and the Cost of Basic Auth - -A published service names one or more methods in the `authentication` clause: - -```sql -authentication basic, session -authentication microflow ProductApi.Authenticate -``` - -| Method | How the caller proves itself | Cost per request | -|---|---|---| -| `basic` | `Authorization: Basic …` on every request | **a full password hash** | -| `session` | an existing session + `X-Csrf-Token` | none, but only reachable from same-origin JavaScript in the same app | -| `guest` | anonymous | none | -| `microflow` | your microflow decides | whatever the microflow does | - -**Basic auth hashes on every call, including failures.** A consumed OData -service holds no session, so every request is a fresh login. Measured on a real -app, that was 60–80% of a page turn — and a *wrong* password costs the same, -because the hash runs either way: - -``` -GET /odata/f1/Drivers?$top=20 - basic auth 200 ~360 ms - wrong password 401 ~346 ms <- hashes either way - session cookie 200 ~19 ms - no credentials 401 ~42 ms -``` - -Do not read the 19 ms as the fix: `session` is only open to same-origin -JavaScript, and a Mendix OData *client* sends basic auth and keeps no cookie. - -### Custom authentication - -A microflow taking `List of System.HttpHeader` and returning a `System.User`. -Return empty to deny. No password hash anywhere, so the ~42 ms floor. - -```sql -CREATE MICROFLOW ProductApi.Authenticate ($Headers: List of System.HttpHeader) - RETURNS System.User AS $User -BEGIN - -- e.g. compare a shared secret from $Headers, then retrieve the service account - retrieve $Users from System.User; - $User = head($Users); - RETURN $User; -END; - -create odata service ProductApi.Api ( ... ) -authentication microflow ProductApi.Authenticate -{ ... }; -``` - -**The client needs no change.** The microflow can read the `Authorization: -Basic …` header itself and compare it against a constant, so the consumer keeps -its existing username/password configuration and the server simply stops -calling BCrypt. - -Two build rules to know before you reach for it: - -- **The microflow is mandatory.** `authentication microflow` with no name parses - but fails the build with **CE0333** "Please select a microflow to use for - authentication". `mxcli check` flags this as `MDL-ODATA04`. -- **App security must be on.** With security off, Mendix reports **CE6600** - "App security is off, but custom authentication is enabled for this service". - Set it with `alter project security level prototype` (or `production`). - -If custom authentication is more than you need, `ALTER SETTINGS MODEL -BcryptCost = 8` shrinks the hash instead of removing it. Each step down halves -the cost; the default is 12. That is a judgement about the accounts in *that* -app — appropriate for machine accounts with generated passwords, not for a -database of human passwords. - -## Step-by-Step: Read-Write API with Microflow Handlers - -For write operations (insert, update, delete), the OData service delegates to microflows that map between the view entity and the underlying persistent entities. - -### Step 1: Create CUD Microflows on the Producer - -Each microflow receives the view entity and an `$HttpRequest` parameter: - -```sql -/** - * Handles INSERT on ProductWithPriceVE. - * Creates a new Product and initial Price entry. - */ -create microflow ProductApi.InsertProductWithPriceVE ( - $ProductWithPriceVE: ProductApi.ProductWithPriceVE, - $HttpRequest: System.HttpRequest -) -begin - -- Map view fields to persistent entities - $Product = create Shop.Product ( - Name = $ProductWithPriceVE/Name, - description = $ProductWithPriceVE/description, - IsActive = true - ); - commit $Product; - - $Price = create Shop.Price ( - PriceInEuro = $ProductWithPriceVE/PriceInEuro, - StartDate = '[%CurrentDateTime%]' - ); - change $Price (Shop.Price_Product = $Product); - commit $Price; -end; - -grant execute on microflow ProductApi.InsertProductWithPriceVE - to ProductApi.ApiUser; - -/** - * Handles UPDATE on ProductWithPriceVE. - * Updates the Product name/description and creates a new Price entry. - */ -create microflow ProductApi.UpdateProductWithPriceVE ( - $ProductWithPriceVE: ProductApi.ProductWithPriceVE, - $HttpRequest: System.HttpRequest -) -begin - retrieve $Product from Shop.Product - where ProductId = $ProductWithPriceVE/ProductId - limit 1; - - change $Product ( - Name = $ProductWithPriceVE/Name, - description = $ProductWithPriceVE/description - ); - commit $Product; -end; - -grant execute on microflow ProductApi.UpdateProductWithPriceVE - to ProductApi.ApiUser; - -/** - * Handles DELETE on ProductWithPriceVE. - * Soft-deletes the product by setting IsActive = false. - */ -create microflow ProductApi.DeleteProductWithPriceVE ( - $ProductWithPriceVE: ProductApi.ProductWithPriceVE, - $HttpRequest: System.HttpRequest -) -begin - retrieve $Product from Shop.Product - where ProductId = $ProductWithPriceVE/ProductId - limit 1; - - change $Product (IsActive = false); - commit $Product; -end; - -grant execute on microflow ProductApi.DeleteProductWithPriceVE - to ProductApi.ApiUser; -``` - -### Step 2: Wire Microflows to Published Entity - -Set `InsertMode`, `UpdateMode`, `DeleteMode` to `CallMicroflow`: - -```sql - publish entity ProductApi.ProductWithPriceVE as 'Product' ( - ReadMode: ReadFromDatabase, - InsertMode: microflow ProductApi.InsertProductWithPriceVE, - UpdateMode: microflow ProductApi.UpdateProductWithPriceVE, - DeleteMode: microflow ProductApi.DeleteProductWithPriceVE - ) - expose (...); -``` - -### Step 3: Grant Write Access on External Entity - -On the consumer side, grant CREATE, WRITE, and DELETE rights: - -```sql -grant ProductClient.User on ProductClient.ProductsEE - (create, delete, read *, write *); -``` - -The consumer can now create, update, and delete products through the OData API, and the producer's microflows handle the mapping to persistent entities. - -## Advanced: Configuration Microflow for Custom Headers - -When the consumer needs to pass custom headers (e.g., for audit trails or user context), use a configuration microflow: - -```sql -/** - * Adds current user name as custom header for audit logging. - */ -create microflow ProductClient.SetClientHeaders ( - $httpResponse: System.HttpResponse -) -returns list of System.HttpHeader as $HttpHeaderList -begin - $HttpHeaderList = create list of System.HttpHeader; - $NewHttpHeader = create System.HttpHeader ( - key = 'X-Audit-User', - value = $currentUser/Name - ); - add $NewHttpHeader to $HttpHeaderList; - return $HttpHeaderList; -end; -``` - -Reference it in the client: - -```sql -create odata client ProductClient.ProductDataApiClient ( - ... - ConfigurationMicroflow: microflow ProductClient.SetClientHeaders -); -``` - ## API Versioning When your API contract changes, create a new version rather than breaking existing consumers: diff --git a/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md b/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md new file mode 100644 index 000000000..a3ba7e581 --- /dev/null +++ b/.claude/skills/mendix/odata-data-sharing/reference/errors-and-auth.md @@ -0,0 +1,259 @@ +# Status codes, capabilities and authentication + +Supporting reference for [odata-data-sharing](../SKILL.md). + +## HTTP Status Codes and Errors: What Each Capability Can Do + +**The read path and the write path have different powers, and the difference is +the single most expensive thing to get wrong here.** Read this before designing +any microflow-backed resource. + +| Capability | Can set the HTTP status code? | How | +|---|---|---| +| OData **action** (published microflow) | **Yes** | add a `System.HttpResponse` parameter | +| Entity **Insertable / Updatable / Deletable** microflow | **Yes** | add a `System.HttpResponse` parameter | +| Entity **Readable** microflow | **No** | not offered — the read capability has no documented `HttpResponse` parameter | + +Sources: [published-odata-microflow §4](https://docs.mendix.com/refguide/published-odata-microflow/#4-customizing-the-outgoing-http-response), +[published-odata-entity, custom HTTP response](https://docs.mendix.com/refguide/published-odata-entity/#custom-http-response). +The custom-response section names Insertable, Updatable and Deletable; the +Readable section never references it. + +### Writing a status code (action / insert / update / delete) + +```sql +create microflow Api.InsertRow ( + $Row: Api.Row, + $HttpResponse: System.HttpResponse +) +begin + if $Row/RowKey = empty then + change $HttpResponse (StatusCode = 400, Content = '{"error":"rowKey is required"}'); + return; + end if; + ... +end; +``` + +Three rules the platform imposes: + +- **`ReasonPhrase` is ignored.** Setting it is dead code; put the explanation in + `Content`. +- **`204` always produces an empty body.** Setting `Content` alongside it is + discarded. +- **Changing status or content makes the whole response come from + `HttpResponse`** — headers included. Changing *only* headers merges them with + the defaults instead. +- `Transfer-Encoding` and `Date` cannot be changed. + +### The read path cannot refuse, so it must not over-promise + +A read microflow has no way to answer `400`. Its only exits are to throw (a +blunt `500`) or to return data. That has two consequences, and both are design +obligations rather than nice-to-haves: + +**1. Declare capabilities you do not implement as `No`.** Mendix applies *no* +query options to a read-microflow resource — it hands over the request and +returns whatever comes back — so `TopSupported` / `SkipSupported` / `Countable` +are claims about your microflow, not about the platform. A resource that +advertises `TopSupported: Yes` and ignores `$top` returns the entire collection +with a `200`, and the client believes it received a page. + +```sql +publish entity Api.Row as 'Rows' ( + ReadMode: microflow Api.Read_Rows, + -- Only claim what Read_Rows actually parses out of the URI: + TopSupported: No, + SkipSupported: No, + Countable: No +) +``` + +Declaring `No` is the read path's substitute for the `400` it cannot send. It is +also safe to declare: mxcli reads these capabilities off the contract, so a +consuming app generated from this service says `No` too. (Until it learned to, +the generator always said `Yes`, and the consumer failed to build with **CE6630** +"'Rows' is marked supports $top=False in the OData service, but True in the app" +— if you hit that against an older mxcli, that is the cause.) + +**The capabilities vocabulary has two annotation shapes**, and it is worth knowing +which you are looking at when reading a `$metadata` by hand: + +| Capability | Shape | +|---|---| +| `TopSupported`, `SkipSupported` | standalone: `` | +| `Insertable`, `Updatable`, `Deletable`, `Countable` | a `` with a `Bool` property value | +| `Filterable`, `Sortable` | **either** — a record listing `NonFilterableProperties` when *some* attributes are filterable, or a bare `Bool="false"` on the record when *none* are | + +That last row is the trap. Mendix picks the shape by arithmetic (there is no list +to write when nothing is filterable), so both appear in one document on different +entity sets, and a service where an entity exposes only a KEY emits the whole-set +form. + +`mxcli check` enforces this as **MDL-ODATA03**, and it looks for the option +*names* in the microflow body, not just for a `System.HttpRequest` parameter — +adding the parameter to answer the KEY (below) does not silence the paging +warning. A microflow that hands the request to a Java action, a JavaScript +action, or a microflow outside the script is left alone, since mxcli cannot read +what those do. + +**2. Answer a lookup by your own KEY.** A client holding a row re-reads it by +key, unprompted, and Mendix's own OData client sends the `$filter` spelling: + +``` +?$filter=rowKey eq '1036-c' ← what the runtime actually sends +/Rows('1036-c') ← bare path key +/Rows(rowKey='1036-c') ← named path key +``` + +If the microflow parses only its collection filter, the key request falls through +to the collection default and the client adopts the **first row** as the identity +of the object it is displaying. There is no error: the request is well-formed, +the response is a valid collection, the count is right, the status is `200`. Two +different objects are then on screen at once, and nothing distinguishes them +until one travels to another page. + +So: `expose ( … (KEY) )` is a promise the *service* makes on the *microflow's* +behalf. Branch key → id → filter → default. + +**Not declaring the KEY is not a way out.** Mendix requires a published entity to +have one — `CE6585 "Published entity 'X' must have a key defined."` — so the only +correct resolution is to answer the lookup. (Query *options* you may decline; +the key you may not.) + +The request itself always arrives on `System.HttpRequest`: + +```sql +create microflow Api.Read_Rows ( + $Request: System.HttpRequest, + $Response: System.ODataResponse -- required while Countable is Yes +) +returns List of Api.Row +begin + log info 'URI=' + $Request/Uri; -- the whole query string, URL-encoded + ... +end; +``` + +To watch what clients actually send, raise the **`OData Publish`** log node (note +the space; it exists only when the project publishes a service): + +```bash +mxcli log set "OData Publish" TRACE +``` +``` +TRACE - OData Publish: Incoming request from 127.0.0.1: GET .../Rows?$top=5&$filter=rowKey eq 'abc' +DEBUG - OData Publish: Responding to client with status code 400. +``` + +That is the fastest way to see a client re-reading a row by key, and it needs no +change to the model. `ODataConsume` is the client side, a different node. + +Mendix validates field names before the microflow runs (`$filter=secretColumn eq 'x'` +is a `400` from the platform), and it enforces `Filterable`: filtering on a property +you did not declare filterable is rejected with `400 "Property 'x' is +non-filterable"` before the microflow runs. So the microflow only ever sees names +that exist in the published metadata. That is defence in depth, not a substitute for a +whitelist — it constrains the *name*, not what you do with it. +## Authentication Methods, and the Cost of Basic Auth + +A published service names one or more methods in the `authentication` clause: + +```sql +authentication basic, session +authentication microflow ProductApi.Authenticate +``` + +| Method | How the caller proves itself | Cost per request | +|---|---|---| +| `basic` | `Authorization: Basic …` on every request | **a full password hash** | +| `session` | an existing session + `X-Csrf-Token` | none, but only reachable from same-origin JavaScript in the same app | +| `guest` | anonymous | none | +| `microflow` | your microflow decides | whatever the microflow does | + +**Basic auth hashes on every call, including failures.** A consumed OData +service holds no session, so every request is a fresh login. Measured on a real +app, that was 60–80% of a page turn — and a *wrong* password costs the same, +because the hash runs either way: + +``` +GET /odata/f1/Drivers?$top=20 + basic auth 200 ~360 ms + wrong password 401 ~346 ms <- hashes either way + session cookie 200 ~19 ms + no credentials 401 ~42 ms +``` + +Do not read the 19 ms as the fix: `session` is only open to same-origin +JavaScript, and a Mendix OData *client* sends basic auth and keeps no cookie. + +### Custom authentication + +A microflow taking `List of System.HttpHeader` and returning a `System.User`. +Return empty to deny. No password hash anywhere, so the ~42 ms floor. + +```sql +CREATE MICROFLOW ProductApi.Authenticate ($Headers: List of System.HttpHeader) + RETURNS System.User AS $User +BEGIN + -- e.g. compare a shared secret from $Headers, then retrieve the service account + retrieve $Users from System.User; + $User = head($Users); + RETURN $User; +END; + +create odata service ProductApi.Api ( ... ) +authentication microflow ProductApi.Authenticate +{ ... }; +``` + +**The client needs no change.** The microflow can read the `Authorization: +Basic …` header itself and compare it against a constant, so the consumer keeps +its existing username/password configuration and the server simply stops +calling BCrypt. + +Two build rules to know before you reach for it: + +- **The microflow is mandatory.** `authentication microflow` with no name parses + but fails the build with **CE0333** "Please select a microflow to use for + authentication". `mxcli check` flags this as `MDL-ODATA04`. +- **App security must be on.** With security off, Mendix reports **CE6600** + "App security is off, but custom authentication is enabled for this service". + Set it with `alter project security level prototype` (or `production`). + +If custom authentication is more than you need, `ALTER SETTINGS MODEL +BcryptCost = 8` shrinks the hash instead of removing it. Each step down halves +the cost; the default is 12. That is a judgement about the accounts in *that* +app — appropriate for machine accounts with generated passwords, not for a +database of human passwords. +## Advanced: Configuration Microflow for Custom Headers + +When the consumer needs to pass custom headers (e.g., for audit trails or user context), use a configuration microflow: + +```sql +/** + * Adds current user name as custom header for audit logging. + */ +create microflow ProductClient.SetClientHeaders ( + $httpResponse: System.HttpResponse +) +returns list of System.HttpHeader as $HttpHeaderList +begin + $HttpHeaderList = create list of System.HttpHeader; + $NewHttpHeader = create System.HttpHeader ( + key = 'X-Audit-User', + value = $currentUser/Name + ); + add $NewHttpHeader to $HttpHeaderList; + return $HttpHeaderList; +end; +``` + +Reference it in the client: + +```sql +create odata client ProductClient.ProductDataApiClient ( + ... + ConfigurationMicroflow: microflow ProductClient.SetClientHeaders +); +``` diff --git a/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md new file mode 100644 index 000000000..73f370eb1 --- /dev/null +++ b/.claude/skills/mendix/odata-data-sharing/reference/walkthroughs.md @@ -0,0 +1,819 @@ +# Step-by-step build walkthroughs + +Supporting reference for [odata-data-sharing](../SKILL.md). + +## Step-by-Step: Read-Only API with View Abstraction + +### Step 1: Create the Producer Module and Role + +```sql +create module ProductApi; + +create module role ProductApi.ApiUser + description 'Role for OData API access'; +``` + +### Step 2: Create View Entities as the API Layer + +Instead of publishing `Shop.Product` and `Shop.Price` directly, create a view that joins and flattens them: + +```sql +/** + * Flattened product with current active price. + * Joins Product with the most recent Price entry. + */ +create view entity ProductApi.ProductWithPriceVE ( + ProductId: integer, + Name: string, + description: string, + PriceInEuro: decimal +) as ( + select p.ID as ProdId + , p.ProductId as ProductId + , p.Name as Name + , p.Description as description + , ( select pr.PriceInEuro + from Shop.Price as pr + where pr.StartDate <= '[%BeginOfTomorrow%]' + and pr/Shop.Price_Product = p.ID + order by pr.StartDate desc + limit 1 + ) as PriceInEuro + from Shop.Product as p + where p.IsActive +); + +grant ProductApi.ApiUser on ProductApi.ProductWithPriceVE + (read *, write *); +``` + +For aggregated data: + +```sql +/** + * Daily sales totals for cheap products. + */ +create view entity ProductApi.CheapProductSalesVE ( + OrderDate: datetime, + TotalItems: long +) as ( + select o.OrderDate as OrderDate + , sum(ol.Amount) as TotalItems + from Shop.OrderLine as ol + left join Shop.OrderLine_Order/Shop."Order" as o + where ol/Shop.OrderLine_Product/Shop.Product.PriceInEuro < 100 + group by o.OrderDate + order by o.OrderDate desc + limit 1000 +); + +grant ProductApi.ApiUser on ProductApi.CheapProductSalesVE + (read *, write *); +``` + +For flattening across associations: + +```sql +/** + * Customer with billing and delivery address flattened into one resource. + */ +create view entity ProductApi.CustomerAddressVE ( + CustomerId: long, + CustomerName: string, + Email: string, + BillingStreet: string, + BillingCity: string, + BillingCountry: string, + DeliveryStreet: string, + DeliveryCity: string, + DeliveryCountry: string +) as ( + select c.ID as CustomerID + , c.CustomerId as CustomerId + , c.FirstName + ' ' + c.LastName as CustomerName + , c.EmailAddress as Email + , ba.Streetname as BillingStreet + , ba.City as BillingCity + , ba.Country as BillingCountry + , da.Streetname as DeliveryStreet + , da.City as DeliveryCity + , da.Country as DeliveryCountry + from Shop.Customer as c + left outer join c/Shop.BillingAddress_Customer/Shop.Address as ba + left outer join c/Shop.DeliveryAddress_Customer/Shop.Address as da +); + +grant ProductApi.ApiUser on ProductApi.CustomerAddressVE + (read *, write *); +``` + +### Step 3: Publish the OData Service + +```sql +/** + * Product and customer data API. + * Exposes flattened views for external consumers. + */ +create odata service ProductApi.ProductDataApi ( + path: 'odata/productdataapi/v1/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'DefaultNamespace', + ServiceName: 'ProductDataApi', + Summary: 'Product and customer data API' + -- PublishAssociations is left at its default (Yes = associations as links). + -- Setting it to No means "associations as an associated object id", which + -- Mendix only allows when the system ID is published as the key — publishing + -- an ordinary attribute as the key then fails the build with CE7375, even + -- when no associations are exposed at all. +) +authentication basic +{ + publish entity ProductApi.ProductWithPriceVE as 'Product' ( + ReadMode: ReadFromDatabase, + InsertMode: NotSupported, + UpdateMode: NotSupported, + DeleteMode: NotSupported + ) + expose ( + ProductId as 'ProductId' (Filterable, Sortable, key), + Name as 'Name' (Filterable, Sortable), + description as 'Description' (Filterable, Sortable), + PriceInEuro as 'PriceInEuro' (Filterable, Sortable) + ); + + publish entity ProductApi.CustomerAddressVE as 'CustomerAddress' ( + ReadMode: ReadFromDatabase, + InsertMode: NotSupported, + UpdateMode: NotSupported, + DeleteMode: NotSupported + ) + expose ( + CustomerId as 'CustomerId' (Filterable, Sortable, key), + CustomerName as 'CustomerName' (Filterable, Sortable), + Email as 'Email' (Filterable, Sortable), + BillingStreet as 'BillingStreet' (Filterable, Sortable), + BillingCity as 'BillingCity' (Filterable, Sortable), + BillingCountry as 'BillingCountry' (Filterable, Sortable), + DeliveryStreet as 'DeliveryStreet' (Filterable, Sortable), + DeliveryCity as 'DeliveryCity' (Filterable, Sortable), + DeliveryCountry as 'DeliveryCountry' (Filterable, Sortable) + ); +}; + +grant access on odata service ProductApi.ProductDataApi + to ProductApi.ApiUser; +``` + +### Step 4: Set Up the Consumer App + +In the consuming application, create an OData client and external entities: + +```sql +create module ProductClient; + +create module role ProductClient.User; + +-- Location constant (configure per environment) +create constant ProductClient.ProductDataApiLocation + type string + default 'http://localhost:8080/odata/productdataapi/v1/'; + +-- OData client connection +create odata client ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'http://localhost:8080/odata/productdataapi/v1/$metadata', + timeout: 300, + ServiceUrl: '@ProductClient.ProductDataApiLocation', + UseAuthentication: Yes, + HttpUsername: 'MxAdmin', + HttpPassword: '1' +); + +-- OData client with local file - relative path (offline development) +-- Resolved relative to .mpr directory when project is loaded +CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: './metadata/productdataapi.xml', + Timeout: 300, + ServiceUrl: '@ProductClient.ProductDataApiLocation', + UseAuthentication: Yes, + HttpUsername: 'MxAdmin', + HttpPassword: '1' +); + +-- OData client with local file - relative path without ./ +CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'metadata/productdataapi.xml', + Timeout: 300, + ServiceUrl: '@ProductClient.ProductDataApiLocation', + UseAuthentication: Yes, + HttpUsername: 'MxAdmin', + HttpPassword: '1' +); + +-- OData client with local file - absolute file:// URI +CREATE ODATA CLIENT ProductClient.ProductDataApiClient ( + ODataVersion: OData4, + MetadataUrl: 'file:///Users/team/contracts/productdataapi.xml', + Timeout: 300, + ServiceUrl: '@ProductClient.ProductDataApiLocation', + UseAuthentication: Yes, + HttpUsername: 'MxAdmin', + HttpPassword: '1' +); + +-- External entities (mapped from published service) +create external entity ProductClient.ProductsEE +from odata client ProductClient.ProductDataApiClient +( + EntitySet: 'Product', + RemoteName: 'Product', + Countable: Yes +) +( + ProductId: long, + Name: string, + description: string, + PriceInEuro: decimal +); + +grant ProductClient.User on ProductClient.ProductsEE (read *); + +create external entity ProductClient.CustomerAddressesEE +from odata client ProductClient.ProductDataApiClient +( + EntitySet: 'CustomerAddress', + RemoteName: 'CustomerAddress', + Countable: Yes +) +( + CustomerId: long, + CustomerName: string, + Email: string, + BillingStreet: string, + BillingCity: string, + BillingCountry: string, + DeliveryStreet: string, + DeliveryCity: string, + DeliveryCountry: string +); + +grant ProductClient.User on ProductClient.CustomerAddressesEE (read *); +``` + +**Bulk alternative:** Instead of creating external entities one by one, import all (or a subset) from the contract: + +```sql +-- All entities from the service +create external entities from ProductClient.ProductDataApiClient; + +-- Or specific ones only +create external entities from ProductClient.ProductDataApiClient + entities (Product, CustomerAddress); + +-- Idempotent re-import +create or modify external entities from ProductClient.ProductDataApiClient; +``` + +### AllowCreateChangeLocally — Read-Only API, Editable in the App + +Use `AllowCreateChangeLocally: Yes` when the remote OData API only supports GET (read-only), but the Mendix app needs to let users edit the data locally before passing it to another API call — for example, an external action or a REST microflow that POSTs the change to a different endpoint. + +Without this flag, external entities are completely non-editable in the client: form widgets are read-only and no in-memory change can be committed. With the flag, Mendix allows the object to be created and changed locally (in memory or in the Mendix database), without trying to write back through the OData client. + +**Typical pattern:** +1. Retrieve data via the OData client into the external entity (GET only). +2. User edits the record in a page — possible because `AllowCreateChangeLocally` is set. +3. A microflow reads the changed object and calls an external action or REST operation (POST/PUT) to submit the change to the remote system. + +```sql +-- API is read-only (no insert/update/delete on the OData endpoint). +-- AllowCreateChangeLocally lets users edit the object in the app +-- and submit changes via a separate external action. +create or modify external entity ShopClient.Product +from odata client ShopClient.ShopApiClient +( + EntitySet: 'Products', + Countable: Yes, + Creatable: No, + Deletable: No, + Updatable: No, + AllowCreateChangeLocally: Yes +) +( + ProductId: long, + Name: string, + Price: decimal +); + +-- Toggle the flag without recreating the entity. +alter entity ShopClient.Product set allow_create_change_locally = true; +alter entity ShopClient.Product set allow_create_change_locally = false; +``` +## Publishing a Non-Persistable Entity (no copy of the data) + +A published entity does **not** have to be persistable. Back it with a read +microflow and the rows are produced per request — nothing is stored, and there +is no refresh job to keep a copy in step with the source. This is the shape to +use when the data lives outside Mendix (an external database, a CSV, an API). + +```sql +create non-persistent entity Api.Lap ( + LapKey: string(60), + Driver: string(120), + LapTime: decimal +); + +-- While Countable is Yes (the default), the read microflow MUST take a +-- $Response: System.ODataResponse parameter — Mendix asks it for the count. +CREATE MICROFLOW Api.Read_Laps ($Response: System.ODataResponse) + RETURNS List of Api.Lap AS $Laps +BEGIN + -- retrieve from wherever the data actually lives, e.g. EXECUTE DATABASE QUERY + $Laps = CREATE LIST OF Api.Lap; + RETURN $Laps; +END; + +create odata service Api.LapApi ( + path: 'odata/laps/', + version: '1.0.0', + ODataVersion: OData4, + namespace: 'Api.Laps' +) +authentication basic +{ + publish entity Api.Lap as 'Laps' ( + ReadMode: microflow Api.Read_Laps, + InsertMode: not_supported, + UpdateMode: not_supported, + DeleteMode: not_supported + ) + expose ( + LapKey as 'lapKey' (KEY, Filterable, Sortable), + Driver (Filterable, Sortable), + LapTime (Sortable) + ); +}; +``` + +Two things worth knowing before you write this: + +- **`ReadMode: microflow Module.MF`** is the whole feature. `InsertMode`, + `UpdateMode` and `DeleteMode` take the same form for a read-write resource. +- **Counting is not free.** If the count means a full scan of the underlying + source, set `Countable: No` on the published entity — the read microflow then + takes no parameters at all. `SkipSupported: No` and `TopSupported: No` turn + off `$skip` and `$top` the same way. All three default to Yes. + +`PublishAssociations` must stay at its default (Yes) — and not only here. + +**It is not a yes/no, it is a two-value representation.** Studio Pro's own labels +for it are "As a link (recommended)" (Yes) and "As an associated object id" (No). +So `PublishAssociations: No` does not mean "this service publishes no +associations"; it selects the legacy representation, which requires the system +`ID` attribute published as the key. MDL cannot publish the system ID (CE1613), +so `No` cannot build from a script. + +That holds even when the service publishes no associations at all, and even for +a persistable entity with a perfectly good key of its own. Measured on Mendix +11.13, both arms of the same service: + +| | `mx check` | +|---|---| +| `PublishAssociations: No` | **CE7375** "Attribute ID … must be published and be the key when associations are exposed as an associated object id" | +| `PublishAssociations: Yes` | 0 errors | + +The error names a concept the script never mentions, which is why this costs +hours rather than minutes. `mxcli check` now warns (MDL-ODATA06). + +**Do not take CE7375's advice literally.** It says to publish the `ID` and make +it the key, and that is the wrong direction for anything you share outside the +app. The two representations exist for a reason: OData v3 had no link support, +so a foreign key had to be an exposed object id; v4 added links largely so +internal ids no longer had to leave the app. Going back to ids gives up that. + +**A published key should be a business key.** Mendix object ids are +autogenerated and are not stable across an app landscape — the same record has +different ids in test, acceptance and production — so an id baked into an +external contract breaks the moment a consumer moves between environments, or +compares data from two of them. Pick something the business already guarantees: +an invoice number, an ISIN, an employee number. Mendix requires a key to be +unique, required and stable (the last is the point here), and the unique +validation rule it makes you add is checking exactly that. + +That is also why the key needs `unique error '…'` on the attribute — see the +CE6624 note below. Both halves of the same idea: the value identifies one row, +and keeps identifying it. + +### A view entity read from the database gets the query options for free + +**This decides whether you need any pushdown machinery at all**, so check it +before reaching for Java. + +A published resource has an `Action`: *Read from database*, or a read microflow. +The difference is not a detail: + +| Action | `$filter` `$orderby` `$top` `$skip` `$count` | +|---|---| +| **Read from database** (a view entity, or a persistable one) | **Mendix applies them** — they reach the database | +| Read microflow | Mendix applies **none** of them; whatever the microflow returns is what the client gets | + +Measured on 11.13 against a running app — an OQL view over four rows aggregating +to three, published with `Action: Read from database` and no Java anywhere: + +``` +$top=1 -> 1 row, not 3 +$count=true&$top=1 -> "@odata.count": 3, one row returned +$filter=Category eq 'Rent' -> only the Rent row +$orderby=Total desc&$skip=1 -> [400, 250] (1500 correctly skipped) +``` + +So for a **view entity**, aggregation happens in the database and paging and +filtering push down to it — a chart or grid can page a large resource with +nothing hand-written. That is the whole capability the `mendix-odata-pushdown` +pack exists to recreate. + +**Which options a read microflow actually has to implement — measured, and not +all-or-nothing.** The same view served both ways on 11.13, three rows behind +each: + +| option | database read | read microflow | +|---|---|---| +| `$select` | applied | **applied** — Mendix projects the response either way | +| `$filter` | applied | **200, unfiltered** | +| `$orderby` | applied | **200, unsorted** | +| `$top` / `$skip` | applied | **200, full set** | +| `$count` | applied | needs `System.ODataResponse` (CE6962) | + +Two things follow that "Mendix applies none of them" gets wrong: + +- **`$select` is not the microflow's correctness problem.** The client already + receives only the fields it asked for. And the consumer drives it: removing + attributes from an external entity narrows the `$select` it sends, because the + external entity has nowhere to put what it dropped. So pushing `$select` into + the source query is a *cost* optimisation — fewer columns read at the source — + never a fix for wrong output. +- **Declaring the capability is what turns a safe refusal into a silent lie.** + With `Filterable`/`Sortable` *not* declared, Mendix rejects the request: + `400 "Property 'Category' is non-filterable."` Declare them — which you must, + or no client can filter at all — and the identical request becomes 200 with + every row. The declaration is a promise Mendix enforces at the boundary and + does not keep for you. + +That second one is the sharpest statement of why this work exists: the failure +is *created by* promising the capability, and the microflow is the only place +left to keep the promise. + +The pack is for the case a view cannot cover: **the data is not in this app's +database at all**, so there is no table for a view to select from and a read +microflow is the only way to produce the rows. Mendix then applies nothing to +them — a `?$top=5` that quietly returns all 917 rows. + +Its motivating shape is two apps, and the topology is what makes the pushdown +load-bearing rather than an optimisation: + +``` +frontend app --- external entities / OData ---> backend app +(grid, chart) (no data of its own) + | + external database connector + | + DuckDB over CSV +``` + +The frontend's grid pages and filters by generating `$top` / `$skip` / +`$filter` — it has no other vocabulary, because external entities *are* OData. +The backend's read microflow has to translate those options into the SQL it +sends through the connector. Without that translation the frontend's paging +still looks correct while every page drags the whole file across, and nothing +in either app reports a problem. + +Two consequences worth holding on to: + +- **A view entity is not an option here**, so "prefer the view" is not advice + that applies. The question is only whether *this app* owns the data. +- **The consumer's capability flags must match the service.** An external + entity generated with `TopSupported`/`SkipSupported` that the service does + not honour is CE6630 in the consuming app — the two ends of this contract + are checked against each other. + +If the resource *is* backed by this app's own tables, prefer a view entity and +skip the machinery entirely. + +### An aggregate view's key is its grain + +A summary resource — an OQL view entity, or a non-persistable row filled by a +read microflow — has no business key to reach for. Monthly totals per category +are not an invoice; nothing in the domain issues them a number. + +**The key is the grain: the columns the aggregate groups by.** For monthly +totals per category that is `(Period, Category)` — together they identify +exactly one row, they are stable because they are the definition of the row, and +they mean the same thing in every environment. + +What not to do is cast the internal id into a column (`cast(c.id as string) as +RowId`) and publish that. It satisfies "a key" and it is the id problem again, +one level down: autogenerated, environment-specific, and now stable only as long +as nobody rebuilds the view. + +Measured on Mendix 11.13, each row a separate build: + +| shape | result | +|---|---| +| single key attribute, persistable, no `unique` rule | **CE6624** — add one | +| single key attribute, persistable, `unique error '…'` | 0 errors | +| **single key attribute, VIEW entity, no `unique` rule** | **0 errors** | +| **composite key, `OData3`** | **CE7238** "You can only have more than one key attribute when the OData version is 4" | +| composite key, `OData4`, persistable, no `unique` rules | 0 errors | +| **composite key, `OData4`, non-persistable, no `unique` rules** | **0 errors** | +| any validation rule on a non-persistable entity | **CE0070** — not allowed | + +Two consequences worth holding on to: + +- **A grain key needs `ODataVersion: OData4`.** More than one key attribute is a + v4 feature; on v3 the same model is CE7238. +- **A composite key needs no `unique` validation rule**, and a non-persistable + entity could not carry one anyway (CE0070). The rule is only demanded for a + *single*-attribute key, where one attribute has to be unique by itself — + which is exactly the case a grain is not. So the CE6624 hurdle disappears + the moment the key is honest about being multi-column. +- **CE6624 does not apply to a view entity at all.** A view can carry a + *single*-attribute key with no validation rule and build cleanly — confirmed + against a Studio Pro service publishing a view keyed on one column. So if the + view already has a naturally unique column (an id carried through from the + source data, not the platform's object id), key on that and skip the grain. + Reach for the grain when no single column identifies a row — which is the + normal case for an aggregate. + +```sql +create non-persistent entity Fin.VMonthCategory ( + Period: string(7), -- 2026-08 + Category: string(60), + Total: decimal +); + +create odata service Fin.ChartApi ( + path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts', + version: '1.0.0', ODataVersion: OData4 -- required: the key is composite +) { + publish entity Fin.VMonthCategory ( + ReadMode: microflow Fin.Read_MonthCategory, + Countable: No -- else CE6962 wants System.ODataResponse + ) + expose ( Period (KEY), Category (KEY), Total ) +} +``` + +(Measured on 11.13: `PublishAssociations: Yes` builds under both `OData3` and +`OData4`, so choosing links is not a v4-only option in current Mendix.) + +**`Path` has two rules and one trap.** No leading slash (CE6550), and it must end +with a single slash (CE6552). A path with **no slash at all** is the trap: mxbuild +throws `System.ArgumentOutOfRangeException` out of its own validator, with no +error code, no element name and no line, which reads as a corrupt project. Use +`'odata/thing/'`. `mxcli check` catches all three (MDL-ODATA05). +## Also Publishing as GraphQL + +`SupportsGraphQL: Yes` makes the same service answer GraphQL as well as OData. +One boolean; the OData surface is untouched. + +```sql +create odata service Fin.ChartApi ( + path: 'odata/charts/', ServiceName: 'ChartApi', Namespace: 'Fin.Charts', + version: '1.0.0', ODataVersion: OData4, + SupportsGraphQL: Yes +) { ... } +``` + +**The GraphQL endpoint is the service location itself** — there is no `/graphql` +path. Clients `POST` a query to the same URL that serves OData: + +``` +GET /odata/charts/$metadata -> the OData contract +POST /odata/charts/ -> {"query":"{ monthCategories { period } }"} +``` + +Verified against a running Mendix 11.13 app: + +| request | response | +|---|---| +| `POST` `{ __schema { queryType { name } } }` | `{"data":{"__schema":{"queryType":{"name":"Query"}}}}` | +| `POST` `{ monthCategories { period category total } }` | `{"data":{"monthCategories":[]}}` | + +Three things that only bite once GraphQL is on: + +- **Query field names are camelCased.** `Period` in the model is `period` in a + query; asking for `Total` returns 400 + `{"errors":[{"message":"Field 'Total' not found"}]}`. The OData names are + unchanged, so the two surfaces spell the same attribute differently. +- **Exposed names must be unique beyond case (CE2881).** Publishing an entity + without `as '...'` gives the entity type and the entity set the same name, + which OData accepts and GraphQL rejects. A service that built yesterday can + fail on the day it is enabled. Give the set its own name: + `publish entity Fin.VMonthCategory as 'MonthCategories'`. +- **`PublishAssociations` must be Yes.** GraphQL has no representation for an + associated object id, so Mendix refuses the pair: CE8055 "A service that + supports GraphQL must publish associations as a link." mxcli refuses it before + writing, since no other change can make it build. +- **Mendix 10.14+**, where it arrived as an experimental feature. mxcli refuses + the statement on an older project rather than writing a property that version's + metamodel does not have — an unknown property is not a build error, it is a + document Studio Pro will not open. + +### What GraphQL actually covers, measured + +The GraphQL surface is narrower than the OData one, and the gaps are not +documented next to the checkbox. Introspected and exercised on 11.13, on a +resource published over both at once: + +| OData | GraphQL | +|---|---| +| `$select` | **inherent** — you name the fields, that *is* the projection | +| `$top` | `first: Int` | +| `$skip` | `offset: Int` | +| `$orderby` | `orderBy: [{field: ASC\|DESC}]` | +| key lookup `Set(K='v')` | a singular field: `vMonthCategory(period: "…", category: "…")` | +| **`$filter`** | **absent** | +| **`$count`** | **absent** | +| `$expand` | not measured here (the probe has no associations) | + +The whole schema for a one-entity service is nine types — `Query`, +`SortOrder`, the entity, its order input, and the scalars. There is no filter +type, no where type and no count type in it. + +Two traps, both measured: + +- **`orderBy` must be a LIST.** `orderBy: {total: DESC}` fails with + `Incorrect value for orderBy`, while `orderBy: [{total: DESC}]` works — and + introspection advertises the argument as a bare input object + (`VMonthCategoryOrderInput`), not a list, so the schema and the parser + disagree. The error does not mention it. +- **An unknown argument is silently ignored.** `monthCategories(where: {…})` + and even `monthCategories(bogusArgument: 42)` both return **200 with the + full result set** rather than an error. A client that assumes a filter + argument exists gets every row and no warning — the same "200 with the wrong + rows" failure the pushdown pack was written about, in a different surface. + +So: **paging and sorting are safe over GraphQL; filtering is not there.** A +widget that needs server-side filtering has to use the OData surface, and a +resource where the client filters is a reason to keep OData even when GraphQL +is enabled. + +GraphQL here is not as complete as the OData surface — it is a second way to read +the same published resources, which some widgets and clients prefer. +## OData Actions: Publishing a Microflow + +An entity set is a *read* surface. To let a client **invoke** something — +`POST /odata/x/RecordNote` with arguments in the body — publish a microflow. +Mendix exposes it in `$metadata` as an `ActionImport`. + +```sql +create odata service ProductApi.Actions ( ... ) +authentication basic +{ + publish microflow ProductApi.RecordNote as 'RecordNote' + expose ( Note as 'note', Amount as 'amount' (CanBeEmpty) ); +}; +``` + +**Parameter data types and the return type are not written in MDL.** They are +read off the microflow, which already declares them — the same thing Studio Pro +does, and the only arrangement in which the two cannot drift. Omitting the +`expose` clause publishes every parameter under its own name. + +### Why this matters more than it looks + +Mendix validates `$filter` against the published metadata **before** the read +microflow runs. So a parameterised *entity set* cannot take its arguments as +filters: `?$filter=driverId eq 'x'` against a resource whose entity has no +`driverId` attribute is answered + +``` +400 Could not map 'driverId' to attribute or association. +``` + +and the microflow never sees it. The workaround is to carry every parameter as +an attribute and echo it back on each row — `SELECT f.*, {driverId} AS driver_id +…`. An action takes them as parameters and needs none of that. + +### Two things Mendix will not do for you + +- **A returning stored procedure cannot be called directly.** The External + Database Connector dispatches a `CALL` as an update, and PgJDBC refuses + because a `CALL` with INOUT parameters answers with a row: *"A result was + returned when none was expected"*. There is no `execute database statement` + activity — only `execute database query`, which wants a `SELECT`. Wrap the + procedure in a one-line function that `CALL`s it and returns its row. +- **The JDBC driver must be declared and shipped even for PostgreSQL**, the + database Mendix itself runs on. Without a module jar dependency the build + fails CE5278; declared but `included = false`, the build is green and the + first request dies with *"No JDBC driver found in app for URL"* — the + Connector resolves drivers from the app's classpath, not the runtime's. Use + `included = true` and `mxcli sync-java-deps`. +## Step-by-Step: Read-Write API with Microflow Handlers + +For write operations (insert, update, delete), the OData service delegates to microflows that map between the view entity and the underlying persistent entities. + +### Step 1: Create CUD Microflows on the Producer + +Each microflow receives the view entity and an `$HttpRequest` parameter: + +```sql +/** + * Handles INSERT on ProductWithPriceVE. + * Creates a new Product and initial Price entry. + */ +create microflow ProductApi.InsertProductWithPriceVE ( + $ProductWithPriceVE: ProductApi.ProductWithPriceVE, + $HttpRequest: System.HttpRequest +) +begin + -- Map view fields to persistent entities + $Product = create Shop.Product ( + Name = $ProductWithPriceVE/Name, + description = $ProductWithPriceVE/description, + IsActive = true + ); + commit $Product; + + $Price = create Shop.Price ( + PriceInEuro = $ProductWithPriceVE/PriceInEuro, + StartDate = '[%CurrentDateTime%]' + ); + change $Price (Shop.Price_Product = $Product); + commit $Price; +end; + +grant execute on microflow ProductApi.InsertProductWithPriceVE + to ProductApi.ApiUser; + +/** + * Handles UPDATE on ProductWithPriceVE. + * Updates the Product name/description and creates a new Price entry. + */ +create microflow ProductApi.UpdateProductWithPriceVE ( + $ProductWithPriceVE: ProductApi.ProductWithPriceVE, + $HttpRequest: System.HttpRequest +) +begin + retrieve $Product from Shop.Product + where ProductId = $ProductWithPriceVE/ProductId + limit 1; + + change $Product ( + Name = $ProductWithPriceVE/Name, + description = $ProductWithPriceVE/description + ); + commit $Product; +end; + +grant execute on microflow ProductApi.UpdateProductWithPriceVE + to ProductApi.ApiUser; + +/** + * Handles DELETE on ProductWithPriceVE. + * Soft-deletes the product by setting IsActive = false. + */ +create microflow ProductApi.DeleteProductWithPriceVE ( + $ProductWithPriceVE: ProductApi.ProductWithPriceVE, + $HttpRequest: System.HttpRequest +) +begin + retrieve $Product from Shop.Product + where ProductId = $ProductWithPriceVE/ProductId + limit 1; + + change $Product (IsActive = false); + commit $Product; +end; + +grant execute on microflow ProductApi.DeleteProductWithPriceVE + to ProductApi.ApiUser; +``` + +### Step 2: Wire Microflows to Published Entity + +Set `InsertMode`, `UpdateMode`, `DeleteMode` to `CallMicroflow`: + +```sql + publish entity ProductApi.ProductWithPriceVE as 'Product' ( + ReadMode: ReadFromDatabase, + InsertMode: microflow ProductApi.InsertProductWithPriceVE, + UpdateMode: microflow ProductApi.UpdateProductWithPriceVE, + DeleteMode: microflow ProductApi.DeleteProductWithPriceVE + ) + expose (...); +``` + +### Step 3: Grant Write Access on External Entity + +On the consumer side, grant CREATE, WRITE, and DELETE rights: + +```sql +grant ProductClient.User on ProductClient.ProductsEE + (create, delete, read *, write *); +``` + +The consumer can now create, update, and delete products through the OData API, and the producer's microflows handle the mapping to persistent entities. diff --git a/.claude/skills/mendix/system-module/SKILL.md b/.claude/skills/mendix/system-module/SKILL.md index 043556891..4237e9a8f 100644 --- a/.claude/skills/mendix/system-module/SKILL.md +++ b/.claude/skills/mendix/system-module/SKILL.md @@ -7,6 +7,13 @@ description: "Reference for the built-in System module — User, FileDocument, I The `System` module is a built-in Mendix module present in every application. It provides core entities for user management, file handling, workflows, task queues, HTTP services, and more. These entities are not defined in the application's domain model but are available for use in microflows, pages, associations, and Java actions. +## Reference files + +- [`reference/workflow-and-queues.md`](reference/workflow-and-queues.md) — the + workflow engine's entities (`WorkflowInstance`, `UserTask`, the state + enumerations and how they relate), plus task queues and scheduled events. Open + it when querying workflow state or wiring anything to a queue. + ## When to Use This Skill Use this skill when: @@ -272,321 +279,6 @@ Tracks offline mobile synchronization failures. --- -## 5. Workflow Engine - -Mendix workflows use a rich set of System entities. These are managed by the runtime but can be queried and displayed in pages. - -### System.Workflow - -A running workflow instance. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Workflow instance name | -| Description | String | Workflow description | -| StartTime | DateTime | When the workflow started | -| EndTime | DateTime | When the workflow ended | -| DueDate | DateTime | Workflow deadline | -| CanBeRestarted | Boolean | Whether restart is allowed | -| CanBeContinued | Boolean | Whether continue is allowed | -| CanApplyJumpTo | Boolean | Whether jump-to is allowed | -| State | Enum (WorkflowState) | Current state | -| Reason | String | Reason for current state (e.g., abort reason) | - -| Association | Target | Type | Description | -|-------------|--------|------|-------------| -| Workflow_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | The workflow template | -| Workflow_ParentWorkflow | System.Workflow | Many-to-One | Parent (for sub-workflows) | - -### System.WorkflowDefinition - -A workflow template as defined in the model. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Definition name | -| Title | String | Display title | -| IsObsolete | Boolean | Whether superseded by newer version | -| IsLocked | Boolean | Whether locked for editing | - -### System.WorkflowUserTask - -An active user task waiting for completion. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task name | -| Description | String | Task description | -| StartTime | DateTime | When the task became active | -| DueDate | DateTime | Task deadline | -| EndTime | DateTime | When the task was completed | -| Outcome | String | Selected outcome | -| State | Enum (WorkflowUserTaskState) | Task state | -| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus is determined | - -| Association | Target | Type | Description | -|-------------|--------|------|-------------| -| WorkflowUserTask_TargetUsers | System.User | Many-to-Many | Eligible users | -| WorkflowUserTask_Assignees | System.User | Many-to-Many | Actually assigned users | -| WorkflowUserTask_Workflow | System.Workflow | Many-to-One | Parent workflow | -| WorkflowUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | Task template | -| WorkflowUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | Eligible user groups | - -### System.WorkflowUserTaskDefinition - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task definition name | -| IsObsolete | Boolean | Whether superseded | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowUserTaskDefinition_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | - -### System.WorkflowGroup - -A named group of users for task assignment. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Group name | -| Description | String | Group description | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowGroup_User | System.User | Many-to-Many | - -### System.WorkflowUserTaskOutcome - -Records who selected which outcome on an active user task. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Outcome | String | Selected outcome value | -| Time | DateTime | When the outcome was selected | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowUserTaskOutcome_WorkflowUserTask | System.WorkflowUserTask | Many-to-One | -| WorkflowUserTaskOutcome_User | System.User | Many-to-One | - -### System.WorkflowEvent - -Audit events during workflow execution. - -| Attribute | Type | Description | -|-----------|------|-------------| -| EventTime | DateTime | When the event occurred | -| EventType | Enum (WorkflowEventType) | Type of event | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEvent_Initiator | System.User | Many-to-One | - -### System.WorkflowRecord - -Snapshot/audit record of a workflow instance. - -| Attribute | Type | Description | -|-----------|------|-------------| -| WorkflowKey | String | Workflow instance key | -| Name | String | Workflow name | -| Description | String | Workflow description | -| State | Enum (WorkflowState) | State at time of record | -| StartTime | DateTime | Workflow start time | -| DueDate | DateTime | Workflow due date | -| EndTime | DateTime | Workflow end time | -| Reason | String | State reason | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowRecord_Workflow | System.Workflow | Many-to-One | -| WorkflowRecord_Owner | System.User | Many-to-One | -| WorkflowRecord_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | - -### System.WorkflowActivityRecord - -Detailed audit of each workflow activity execution. - -| Attribute | Type | Description | -|-----------|------|-------------| -| ModelGUID | String | Activity GUID in model | -| ActivityKey | String | Unique activity key | -| PreviousActivityKey | String | Key of preceding activity | -| ActivityType | Enum (WorkflowActivityType) | Type of activity | -| Caption | String | Activity caption | -| State | Enum (WorkflowActivityExecutionState) | Execution state | -| StartTime | DateTime | When activity started | -| EndTime | DateTime | When activity ended | -| Outcome | String | Activity outcome | -| MicroflowName | String | Called microflow (if applicable) | -| TaskName | String | User task name (if applicable) | -| TaskDescription | String | User task description | -| TaskDueDate | DateTime | User task due date | -| TaskCompletionType | Enum (WorkflowUserTaskCompletionType) | How task consensus works | -| TaskRequiredUsers | Integer | Number of required users | -| TaskKey | String | User task key | -| Reason | String | State reason | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowActivityRecord_PreviousActivity | System.WorkflowActivityRecord | Many-to-One | -| WorkflowActivityRecord_Actor | System.User | Many-to-One | -| WorkflowActivityRecord_SubWorkflow | System.WorkflowRecord | Many-to-One | -| WorkflowActivityRecord_UserTask | System.WorkflowUserTask | Many-to-One | -| WorkflowActivityRecord_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | -| WorkflowActivityRecord_TaskTargetedUsers | System.User | Many-to-Many | -| WorkflowActivityRecord_TaskAssignedUsers | System.User | Many-to-Many | -| WorkflowActivityRecord_TaskTargetedGroups | System.WorkflowGroup | Many-to-Many | - -### System.WorkflowActivityDetails - -Metadata about a workflow activity (used for jump-to navigation). - -| Attribute | Type | Description | -|-----------|------|-------------| -| ActivityId | String | Activity identifier | -| ActivityCaption | String | Display caption | -| ActivityType | Enum (WorkflowActivityType) | Activity type | -| ExistsInCurrentVersion | Boolean | Whether activity exists in current model version | - -### System.WorkflowCurrentActivity - -Current activity state within a workflow (used for jump-to). - -| Attribute | Type | Description | -|-----------|------|-------------| -| Action | Enum (WorkflowCurrentActivityAction) | DoNothing or JumpTo | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowCurrentActivity_ActivityDetails | System.WorkflowActivityDetails | Many-to-One | -| WorkflowCurrentActivity_ApplicableTargets | System.WorkflowActivityDetails | Many-to-Many | -| WorkflowCurrentActivity_JumpToTarget | System.WorkflowActivityDetails | Many-to-One | - -### System.WorkflowJumpToDetails - -Details for jump-to operations. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Error | String | Error message if jump-to failed | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowJumpToDetails_Workflow | System.Workflow | Many-to-One | -| WorkflowJumpToDetails_CurrentActivities | System.WorkflowCurrentActivity | Many-to-Many | - -### System.WorkflowEndedUserTask - -Completed/archived user tasks. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Task name | -| Description | String | Task description | -| StartTime | DateTime | When task started | -| DueDate | DateTime | Task deadline | -| EndTime | DateTime | When task ended | -| Outcome | String | Final outcome | -| State | Enum (WorkflowUserTaskState) | Final state | -| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus was determined | -| UserTaskKey | String | Unique task key | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEndedUserTask_Assignees | System.User | Many-to-Many | -| WorkflowEndedUserTask_TargetUsers | System.User | Many-to-Many | -| WorkflowEndedUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | -| WorkflowEndedUserTask_Workflow | System.Workflow | Many-to-One | -| WorkflowEndedUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | - -### System.WorkflowEndedUserTaskOutcome - -Individual outcome votes on ended user tasks. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Outcome | String | Selected outcome | -| Time | DateTime | When outcome was selected | - -| Association | Target | Type | -|-------------|--------|------| -| WorkflowEndedUserTaskOutcome_User | System.User | Many-to-One | -| WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask | System.WorkflowEndedUserTask | Many-to-One | - ---- - -## 6. Task Queues & Scheduled Events - -### System.QueuedTask - -A task waiting to execute or currently running in a task queue. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Sequence | Long | Task sequence number | -| Status | Enum (QueueTaskStatus) | Current status | -| QueueId | String | Queue identifier | -| QueueName | String | Queue display name | -| ContextType | Enum (ContextType) | Execution context: System, User, Anonymous, ScheduledEvent | -| ContextData | String | Serialized context | -| MicroflowName | String | Microflow to execute | -| UserActionName | String | Java action to execute | -| Arguments | String | Serialized arguments | -| XASId | String | Cluster node identifier | -| ThreadId | Long | Execution thread ID | -| Created | DateTime | When task was queued | -| StartAt | DateTime | Scheduled start time | -| Started | DateTime | Actual start time | -| Retried | Long | Number of retry attempts | -| Retry | String | Retry configuration | -| ScheduledEventName | String | Associated scheduled event name | - -### System.ProcessedQueueTask - -Completed tasks (audit trail). Same attributes as `QueuedTask` plus: - -| Attribute | Type | Description | -|-----------|------|-------------| -| Finished | DateTime | When task finished | -| Duration | Long | Execution duration in milliseconds | -| ErrorMessage | String | Error message if task failed | - -### System.ScheduledEventInformation - -Runtime information about scheduled events. - -| Attribute | Type | Description | -|-----------|------|-------------| -| Name | String | Scheduled event name | -| Description | String | Event description | -| StartTime | DateTime | Last start time | -| EndTime | DateTime | Last end time | -| Status | Enum (EventStatus) | Running, Completed, Error, Stopped | - -| Association | Target | Type | -|-------------|--------|------| -| ScheduledEventInformation_XASInstance | System.XASInstance | Many-to-One | - -### System.XASInstance - -Cluster node information (for multi-instance deployments). - -| Attribute | Type | Description | -|-----------|------|-------------| -| XASId | String | Node identifier | -| LastUpdate | DateTime | Last heartbeat | -| AllowedNumberOfConcurrentUsers | Integer | License limit | -| PartnerName | String | Partner name (licensing) | -| CustomerName | String | Customer name (licensing) | - -### System.TaskQueueToken - -Token for task queue operations (internal use). - ---- - ## 7. Utility Entities ### System.Paging (non-persistent) diff --git a/.claude/skills/mendix/system-module/reference/workflow-and-queues.md b/.claude/skills/mendix/system-module/reference/workflow-and-queues.md new file mode 100644 index 000000000..f072e3f3b --- /dev/null +++ b/.claude/skills/mendix/system-module/reference/workflow-and-queues.md @@ -0,0 +1,317 @@ +# Workflow engine, task queues and scheduled events + +Supporting reference for [system-module](../SKILL.md). + +## 5. Workflow Engine + +Mendix workflows use a rich set of System entities. These are managed by the runtime but can be queried and displayed in pages. + +### System.Workflow + +A running workflow instance. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Workflow instance name | +| Description | String | Workflow description | +| StartTime | DateTime | When the workflow started | +| EndTime | DateTime | When the workflow ended | +| DueDate | DateTime | Workflow deadline | +| CanBeRestarted | Boolean | Whether restart is allowed | +| CanBeContinued | Boolean | Whether continue is allowed | +| CanApplyJumpTo | Boolean | Whether jump-to is allowed | +| State | Enum (WorkflowState) | Current state | +| Reason | String | Reason for current state (e.g., abort reason) | + +| Association | Target | Type | Description | +|-------------|--------|------|-------------| +| Workflow_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | The workflow template | +| Workflow_ParentWorkflow | System.Workflow | Many-to-One | Parent (for sub-workflows) | + +### System.WorkflowDefinition + +A workflow template as defined in the model. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Definition name | +| Title | String | Display title | +| IsObsolete | Boolean | Whether superseded by newer version | +| IsLocked | Boolean | Whether locked for editing | + +### System.WorkflowUserTask + +An active user task waiting for completion. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task name | +| Description | String | Task description | +| StartTime | DateTime | When the task became active | +| DueDate | DateTime | Task deadline | +| EndTime | DateTime | When the task was completed | +| Outcome | String | Selected outcome | +| State | Enum (WorkflowUserTaskState) | Task state | +| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus is determined | + +| Association | Target | Type | Description | +|-------------|--------|------|-------------| +| WorkflowUserTask_TargetUsers | System.User | Many-to-Many | Eligible users | +| WorkflowUserTask_Assignees | System.User | Many-to-Many | Actually assigned users | +| WorkflowUserTask_Workflow | System.Workflow | Many-to-One | Parent workflow | +| WorkflowUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | Task template | +| WorkflowUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | Eligible user groups | + +### System.WorkflowUserTaskDefinition + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task definition name | +| IsObsolete | Boolean | Whether superseded | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowUserTaskDefinition_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | + +### System.WorkflowGroup + +A named group of users for task assignment. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Group name | +| Description | String | Group description | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowGroup_User | System.User | Many-to-Many | + +### System.WorkflowUserTaskOutcome + +Records who selected which outcome on an active user task. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Outcome | String | Selected outcome value | +| Time | DateTime | When the outcome was selected | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowUserTaskOutcome_WorkflowUserTask | System.WorkflowUserTask | Many-to-One | +| WorkflowUserTaskOutcome_User | System.User | Many-to-One | + +### System.WorkflowEvent + +Audit events during workflow execution. + +| Attribute | Type | Description | +|-----------|------|-------------| +| EventTime | DateTime | When the event occurred | +| EventType | Enum (WorkflowEventType) | Type of event | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEvent_Initiator | System.User | Many-to-One | + +### System.WorkflowRecord + +Snapshot/audit record of a workflow instance. + +| Attribute | Type | Description | +|-----------|------|-------------| +| WorkflowKey | String | Workflow instance key | +| Name | String | Workflow name | +| Description | String | Workflow description | +| State | Enum (WorkflowState) | State at time of record | +| StartTime | DateTime | Workflow start time | +| DueDate | DateTime | Workflow due date | +| EndTime | DateTime | Workflow end time | +| Reason | String | State reason | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowRecord_Workflow | System.Workflow | Many-to-One | +| WorkflowRecord_Owner | System.User | Many-to-One | +| WorkflowRecord_WorkflowDefinition | System.WorkflowDefinition | Many-to-One | + +### System.WorkflowActivityRecord + +Detailed audit of each workflow activity execution. + +| Attribute | Type | Description | +|-----------|------|-------------| +| ModelGUID | String | Activity GUID in model | +| ActivityKey | String | Unique activity key | +| PreviousActivityKey | String | Key of preceding activity | +| ActivityType | Enum (WorkflowActivityType) | Type of activity | +| Caption | String | Activity caption | +| State | Enum (WorkflowActivityExecutionState) | Execution state | +| StartTime | DateTime | When activity started | +| EndTime | DateTime | When activity ended | +| Outcome | String | Activity outcome | +| MicroflowName | String | Called microflow (if applicable) | +| TaskName | String | User task name (if applicable) | +| TaskDescription | String | User task description | +| TaskDueDate | DateTime | User task due date | +| TaskCompletionType | Enum (WorkflowUserTaskCompletionType) | How task consensus works | +| TaskRequiredUsers | Integer | Number of required users | +| TaskKey | String | User task key | +| Reason | String | State reason | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowActivityRecord_PreviousActivity | System.WorkflowActivityRecord | Many-to-One | +| WorkflowActivityRecord_Actor | System.User | Many-to-One | +| WorkflowActivityRecord_SubWorkflow | System.WorkflowRecord | Many-to-One | +| WorkflowActivityRecord_UserTask | System.WorkflowUserTask | Many-to-One | +| WorkflowActivityRecord_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | +| WorkflowActivityRecord_TaskTargetedUsers | System.User | Many-to-Many | +| WorkflowActivityRecord_TaskAssignedUsers | System.User | Many-to-Many | +| WorkflowActivityRecord_TaskTargetedGroups | System.WorkflowGroup | Many-to-Many | + +### System.WorkflowActivityDetails + +Metadata about a workflow activity (used for jump-to navigation). + +| Attribute | Type | Description | +|-----------|------|-------------| +| ActivityId | String | Activity identifier | +| ActivityCaption | String | Display caption | +| ActivityType | Enum (WorkflowActivityType) | Activity type | +| ExistsInCurrentVersion | Boolean | Whether activity exists in current model version | + +### System.WorkflowCurrentActivity + +Current activity state within a workflow (used for jump-to). + +| Attribute | Type | Description | +|-----------|------|-------------| +| Action | Enum (WorkflowCurrentActivityAction) | DoNothing or JumpTo | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowCurrentActivity_ActivityDetails | System.WorkflowActivityDetails | Many-to-One | +| WorkflowCurrentActivity_ApplicableTargets | System.WorkflowActivityDetails | Many-to-Many | +| WorkflowCurrentActivity_JumpToTarget | System.WorkflowActivityDetails | Many-to-One | + +### System.WorkflowJumpToDetails + +Details for jump-to operations. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Error | String | Error message if jump-to failed | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowJumpToDetails_Workflow | System.Workflow | Many-to-One | +| WorkflowJumpToDetails_CurrentActivities | System.WorkflowCurrentActivity | Many-to-Many | + +### System.WorkflowEndedUserTask + +Completed/archived user tasks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Task name | +| Description | String | Task description | +| StartTime | DateTime | When task started | +| DueDate | DateTime | Task deadline | +| EndTime | DateTime | When task ended | +| Outcome | String | Final outcome | +| State | Enum (WorkflowUserTaskState) | Final state | +| CompletionType | Enum (WorkflowUserTaskCompletionType) | How consensus was determined | +| UserTaskKey | String | Unique task key | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEndedUserTask_Assignees | System.User | Many-to-Many | +| WorkflowEndedUserTask_TargetUsers | System.User | Many-to-Many | +| WorkflowEndedUserTask_WorkflowUserTaskDefinition | System.WorkflowUserTaskDefinition | Many-to-One | +| WorkflowEndedUserTask_Workflow | System.Workflow | Many-to-One | +| WorkflowEndedUserTask_TargetGroups | System.WorkflowGroup | Many-to-Many | + +### System.WorkflowEndedUserTaskOutcome + +Individual outcome votes on ended user tasks. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Outcome | String | Selected outcome | +| Time | DateTime | When outcome was selected | + +| Association | Target | Type | +|-------------|--------|------| +| WorkflowEndedUserTaskOutcome_User | System.User | Many-to-One | +| WorkflowEndedUserTaskOutcome_WorkflowEndedUserTask | System.WorkflowEndedUserTask | Many-to-One | + +--- +## 6. Task Queues & Scheduled Events + +### System.QueuedTask + +A task waiting to execute or currently running in a task queue. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Sequence | Long | Task sequence number | +| Status | Enum (QueueTaskStatus) | Current status | +| QueueId | String | Queue identifier | +| QueueName | String | Queue display name | +| ContextType | Enum (ContextType) | Execution context: System, User, Anonymous, ScheduledEvent | +| ContextData | String | Serialized context | +| MicroflowName | String | Microflow to execute | +| UserActionName | String | Java action to execute | +| Arguments | String | Serialized arguments | +| XASId | String | Cluster node identifier | +| ThreadId | Long | Execution thread ID | +| Created | DateTime | When task was queued | +| StartAt | DateTime | Scheduled start time | +| Started | DateTime | Actual start time | +| Retried | Long | Number of retry attempts | +| Retry | String | Retry configuration | +| ScheduledEventName | String | Associated scheduled event name | + +### System.ProcessedQueueTask + +Completed tasks (audit trail). Same attributes as `QueuedTask` plus: + +| Attribute | Type | Description | +|-----------|------|-------------| +| Finished | DateTime | When task finished | +| Duration | Long | Execution duration in milliseconds | +| ErrorMessage | String | Error message if task failed | + +### System.ScheduledEventInformation + +Runtime information about scheduled events. + +| Attribute | Type | Description | +|-----------|------|-------------| +| Name | String | Scheduled event name | +| Description | String | Event description | +| StartTime | DateTime | Last start time | +| EndTime | DateTime | Last end time | +| Status | Enum (EventStatus) | Running, Completed, Error, Stopped | + +| Association | Target | Type | +|-------------|--------|------| +| ScheduledEventInformation_XASInstance | System.XASInstance | Many-to-One | + +### System.XASInstance + +Cluster node information (for multi-instance deployments). + +| Attribute | Type | Description | +|-----------|------|-------------| +| XASId | String | Node identifier | +| LastUpdate | DateTime | Last heartbeat | +| AllowedNumberOfConcurrentUsers | Integer | License limit | +| PartnerName | String | Partner name (licensing) | +| CustomerName | String | Customer name (licensing) | + +### System.TaskQueueToken + +Token for task queue operations (internal use). + +--- diff --git a/.claude/skills/mendix/test-microflows/SKILL.md b/.claude/skills/mendix/test-microflows/SKILL.md index 06d443854..5ad788c53 100644 --- a/.claude/skills/mendix/test-microflows/SKILL.md +++ b/.claude/skills/mendix/test-microflows/SKILL.md @@ -7,6 +7,13 @@ description: "Write and run MDL-based microflow tests with `mxcli test` — anno This skill guides you through writing and running MDL-based microflow tests using `mxcli test`. +## Reference files + +- [`reference/annotations.md`](reference/annotations.md) — every test annotation: + `@test`, `@setup`, `@teardown`, `@cleanup`, `@skip`, parameters and expected + values, with what each one does and the exact spelling. **Check an annotation + here rather than guessing** — an unknown one is a parse error, not a warning. + ## When to Use This Skill Use this when: @@ -164,292 +171,6 @@ The markdown format turns your tests into living documentation. --- -## Annotations - -| Tag | Purpose | Example | -|-----|---------|---------| -| `@test` | Test name (required) | `@test string concatenation` | -| `@expect` | Assert a Mendix condition | `@expect $result = 'John Doe'` | -| `@expect` | Assert an entity attribute | `@expect $product/Name = 'TestProduct'` | -| `@expect` | Assert with a built-in | `@expect length($result) = 81` | -| `@verify` | OQL post-condition on the database | `@verify select count(*) as n from Mod.E = 1` | -| `@throws` | Expect error | `@throws 'validation failed'` | -| `@setup` | Microflow to run first | `@setup MyModule.ACT_SeedCustomers` | -| `@cleanup` | Rollback strategy | `@cleanup rollback` (default) or `@cleanup none` | - -A tag is read only when it **opens its line** (after the javadoc `*` and its -indentation). Quoting one inside a sentence — ``a test with `@expect $x = 1` -asserts …`` — is documentation, not an annotation, so a doc comment can explain -itself without giving the test assertions nobody wrote. - -### A test run leaves the project byte-identical - -`mxcli test` injects an `MxTest` module, builds, runs, and takes the injection -back out. When cleanup succeeds the project file is restored **byte-for-byte**, -so `git status` is clean afterwards and a CI step of the form "run the tests, -then assert the tree is clean" holds. - -This needs saying because restoring the *model* is not enough. Every unit write -stamps a fresh UUID into the `.mpr`'s `_Transaction` bookkeeping row, and the -inject/remove cycle relays SQLite's pages, so the file differs even once its -content matches again. Version control compares bytes, and a `.mpr` diff is -opaque — there is no cheap way to tell a bookkeeping GUID from a real model edit, -which is what made the spurious modification expensive rather than merely untidy. - -The restore is declined, deliberately, when **cleanup failed** or when the -`mprcontents/` tree changed during the run. In both cases the project is not in -the state the snapshot describes, and putting the old file back would turn a -visible, harmless discrepancy into an invisible, misleading one. - -### `@expect` — any Mendix condition, and nothing it cannot evaluate - -An `@expect` is **a Mendix expression that must evaluate to true**, not a fixed -`$var = value` shape. Anything the Mendix expression engine accepts works: - -```mdl -@expect $result = 'John Doe' -- equality -@expect $product/Name != 'Widget' -- inequality (<> also accepted) -@expect length($result) = 81 -- built-in functions -@expect find($result, '0') >= 0 -- any comparison operator -@expect substring($result, 0, 9) = substring($result, 9, 18) -@expect find($result, '0') >= 0 and $count > 3 -- and / or / not(...) -@expect $status = MyModule.Status.Open -- enumeration values -@expect count($Customers) = 5 -- how many rows a list holds -``` - -`<>` is accepted in the annotation and rewritten to `!=` on the way to the -model, because Mendix's expression engine rejects the `<>` spelling (CE0117). - -**An assertion the runner cannot compile is an ERROR, never a pass.** Unknown -functions, wrong arity, unbalanced parentheses and expressions that evaluate to -a value rather than a condition are all rejected by name: - -``` -ERROR a self-evident falsehood - @expect randomInt($result) = 1: randomInt() is not a Mendix expression - function at column 1 ("randomInt") -``` - -This is the one rule the annotation is built around. A test framework that -cannot evaluate an assertion has exactly one safe behaviour, and passing is not -it — an earlier version matched only `$var = ` and silently discarded -every other line, so `@expect 1 = 2` reported PASS. - -**A failure reports what came back**, not just what was wanted, whenever the -observed value's type is pinned down by the assertion itself: - -``` -FAIL the board is 81 squares - expected length($result) = 81, actual: 27 -``` - -The value is omitted rather than guessed when neither side of the comparison -establishes a type (`@expect $a = $b`), because Mendix's expression engine is -typed and a wrong guess would break the build instead of the test. - -#### `count($list)` — the one aggregate an assertion can make - -Counting a list is not a Mendix *expression* function; it is an Aggregate list -**activity**, so it cannot appear in the decision that evaluates an assertion. -`@expect count($Scans) = 2` is nevertheless accepted: the count is lifted into -the activity you would otherwise write by hand, ahead of the decision, and the -condition compares its result. - -```mdl -/** - * @test the seed microflow writes five brands - * @cleanup none - * @expect count($Brands) = 5 - */ -retrieve $Brands from eShop.CatalogBrand; -/ -``` - -The other four aggregates (`sum`, `average`, `minimum`, `maximum`) aggregate an -**attribute** over the list, which an assertion has no way to supply, so they are -refused with that explanation. Call a microflow that returns the figure and -assert on its result: - -```mdl -$Total = call microflow eShop.QRY_OrderTotal(); -``` - -The refusal matters more than the convenience: before it, a count assertion was -dropped during parsing, and a test with no assertions left passes as long as its -body does not throw — so `@expect count($Brands) = 999` reported PASS against an -empty table (#927). - -### `@setup` — the state a test needs before it runs - -`@setup` names a **microflow** to call before the test's own statements. A -fixture in a Mendix app is a microflow, so there is nothing to declare: - -```mdl -/** - * @test the seed microflow writes five brands - * @setup eShop.ACT_SeedCatalog - * @cleanup none - * @expect count($Brands) = 5 - */ -retrieve $Brands from eShop.CatalogBrand; -/ -``` - -Repeat it to compose fixtures; they run in the order written. Declare it **once -in the file's header comment** and every test in the file gets it, with the -file's fixtures running before a test's own: - -```mdl -/** - * Seeds every test below. - * @setup eShop.ACT_SeedCatalog - */ -``` - -The header is the file's first doc comment when it carries no `@test`. It may -only carry `@setup` — `@expect`, `@verify`, `@throws` and `@cleanup` describe one -test's execution, so a header carrying one is refused by name rather than -silently ignored. - -Two consequences worth knowing: - -- **The setup runs inside the test's transaction.** Under the `@cleanup - rollback` default it is undone with the test, so every test starts from the - same state — which is what makes a fixture worth having. Under `@cleanup none` - it persists like everything else that test writes. -- **A failing setup is an ERROR, not a FAIL**, naming the microflow. The test - never ran, so it neither passed nor failed, and a suite full of assertion - mismatches caused by one broken seed is exactly what this prevents. - -`@setup` calls a microflow with no arguments; a fixture that needs arguments -gets a wrapper microflow. There is no `@teardown` — `@cleanup rollback` is the -teardown. - -### `@verify` — asserting on what the microflow wrote - -`@expect` can only see what a microflow **returned**. Most Mendix microflows are -side effects, so `@verify` is how you assert on the rows one left behind: an OQL -query, a comparison, and the value it must satisfy. - -```mdl -/** - * @test dealing a board writes 81 cells - * @cleanup none - * @expect $result = 'ok' - * @verify select count(*) as n from Sudoku.Cell = 81 - * @verify select count(*) as n from Sudoku.Cell where Value = 0 > 0 - */ -$result = CALL MICROFLOW Sudoku.ACT_DealGame(); -/ -``` - -The query runs against the app **after** the microflow returns, over the same -admin API `mxcli oql` uses. Three rules follow from that, and each is enforced -rather than left to trip you up: - -- **`@cleanup none` is required.** `rollback` is the default, and it undoes the - test's writes before the query could see them — so a `@verify` on a rollback - test is **refused**, not run against the pre-test state. -- **The query must return exactly one row and one column.** Comparing a table to - a literal would mean guessing which cell was meant. Aggregate it - (`select count(*)`), or select one attribute of one row. -- **The expected value is a literal** — a number, a quoted string, `true`/`false` - or `empty`. It is split off at the **last** comparison operator outside quotes - and parentheses, so a `where Value = 5` in the query is left alone. -- **Every selected column needs a name.** Mendix's OQL rejects a bare - `select count(*)` with *"All OQL select columns must have a name"*, so write - `select count(*) as n`. That comes back as an ERROR, not a pass. - -Operators: `=`, `!=` (`<>` accepted), `<`, `<=`, `>`, `>=`. Numbers compare -numerically even though the runtime returns them as strings. - -**A `@verify` that cannot be evaluated is an ERROR, never a pass** — an unknown -entity, malformed OQL, a non-scalar result, or something that was never a query: - -``` -ERROR dealing a board writes 81 cells - @verify select count(*) as n from Sudoku.NoSuch = 1: OQL error: Unknown entity -``` - -and a false one fails with the value that came back: - -``` -FAIL dealing a board writes 81 cells - expected select count(*) as n from Sudoku.Cell = 81, actual: 27 -``` - -`@verify` needs the test endpoint, so it runs under `--local` (the default) and -`--attach`. The Docker / `--legacy-runner` path **refuses** a suite using it: -its tests execute during boot, so there is no point at which to query the app. - -### A test that asserts nothing says so - -Every result line carries what the test actually checked, and a run that -contains a vacuous test calls it out: - -``` - PASS the board is 81 squares (6ms, 2 assertions) - PASS asserts nothing at all (4ms, no assertions) ------------------------------------------------------------- -1 test(s) asserted nothing beyond "did not throw". Run with ---require-assertions to make that an error. -``` - -A test with no `@expect` and no `@throws` is a **smoke test** — it reports only -that the body did not throw. That is a legitimate thing to write, so it still -passes by default. What it may not do is look identical to a test with six -assertions: after `@expect` started failing closed, the cheapest way back to a -green suite is to delete the assertion, and that must not read as a repair. - -`--require-assertions` turns every vacuous test into an ERROR, for a project -that has decided each test must assert. The JUnit report carries the count as a -`` per case, and `classname` now identifies the -source file, so a failure in a multi-file run says where it lives. - -### `@cleanup` — what happens to a test's data - -**`rollback` is the default**, so by default a test's database writes do not -survive it. The endpoint opens a transaction around the call and rolls it back -afterwards, including when the test throws. - -```mdl -/** - * @test creating an order does not leak - * @expect $result = 'ok' - */ -$result = CALL MICROFLOW Sales.CreateOrder(Amount = 100); -/ - -/** - * @test seed data the next test needs - * @cleanup none - */ -$result = CALL MICROFLOW Sales.SeedCatalogue(); -/ -``` - -Use `@cleanup none` when the writes are the point — seeding a fixture, or -inspecting the result in the running app afterwards. - -Two things worth knowing: - -- **`--local` only.** Rollback needs the test endpoint, which owns the context - the test runs in. The Docker / `--legacy-runner` path executes tests inside - the after-startup action and has no such seam, so it always commits. -- **A rollback that fails is reported, loudly.** The run prints a `WARNING` per - affected test and a summary line, because the alternative — data left behind - while the suite still says PASS — is the failure mode this annotation exists - to prevent. `--verbose` tags every test with `[rolled back]`, `[committed]` or - `[ROLLBACK FAILED]`. - -A misspelled strategy (`@cleanup rollbak`) is a **parse error**, not a silent -fallback to committing. - -Rollback matters most under `--attach`, where the database is the one your dev -app is using. - ---- - ## Running Tests ```bash diff --git a/.claude/skills/mendix/test-microflows/reference/annotations.md b/.claude/skills/mendix/test-microflows/reference/annotations.md new file mode 100644 index 000000000..1aede67b7 --- /dev/null +++ b/.claude/skills/mendix/test-microflows/reference/annotations.md @@ -0,0 +1,289 @@ +# Test annotation reference + +Supporting reference for [test-microflows](../SKILL.md). + +## Annotations + +| Tag | Purpose | Example | +|-----|---------|---------| +| `@test` | Test name (required) | `@test string concatenation` | +| `@expect` | Assert a Mendix condition | `@expect $result = 'John Doe'` | +| `@expect` | Assert an entity attribute | `@expect $product/Name = 'TestProduct'` | +| `@expect` | Assert with a built-in | `@expect length($result) = 81` | +| `@verify` | OQL post-condition on the database | `@verify select count(*) as n from Mod.E = 1` | +| `@throws` | Expect error | `@throws 'validation failed'` | +| `@setup` | Microflow to run first | `@setup MyModule.ACT_SeedCustomers` | +| `@cleanup` | Rollback strategy | `@cleanup rollback` (default) or `@cleanup none` | + +A tag is read only when it **opens its line** (after the javadoc `*` and its +indentation). Quoting one inside a sentence — ``a test with `@expect $x = 1` +asserts …`` — is documentation, not an annotation, so a doc comment can explain +itself without giving the test assertions nobody wrote. + +### A test run leaves the project byte-identical + +`mxcli test` injects an `MxTest` module, builds, runs, and takes the injection +back out. When cleanup succeeds the project file is restored **byte-for-byte**, +so `git status` is clean afterwards and a CI step of the form "run the tests, +then assert the tree is clean" holds. + +This needs saying because restoring the *model* is not enough. Every unit write +stamps a fresh UUID into the `.mpr`'s `_Transaction` bookkeeping row, and the +inject/remove cycle relays SQLite's pages, so the file differs even once its +content matches again. Version control compares bytes, and a `.mpr` diff is +opaque — there is no cheap way to tell a bookkeeping GUID from a real model edit, +which is what made the spurious modification expensive rather than merely untidy. + +The restore is declined, deliberately, when **cleanup failed** or when the +`mprcontents/` tree changed during the run. In both cases the project is not in +the state the snapshot describes, and putting the old file back would turn a +visible, harmless discrepancy into an invisible, misleading one. + +### `@expect` — any Mendix condition, and nothing it cannot evaluate + +An `@expect` is **a Mendix expression that must evaluate to true**, not a fixed +`$var = value` shape. Anything the Mendix expression engine accepts works: + +```mdl +@expect $result = 'John Doe' -- equality +@expect $product/Name != 'Widget' -- inequality (<> also accepted) +@expect length($result) = 81 -- built-in functions +@expect find($result, '0') >= 0 -- any comparison operator +@expect substring($result, 0, 9) = substring($result, 9, 18) +@expect find($result, '0') >= 0 and $count > 3 -- and / or / not(...) +@expect $status = MyModule.Status.Open -- enumeration values +@expect count($Customers) = 5 -- how many rows a list holds +``` + +`<>` is accepted in the annotation and rewritten to `!=` on the way to the +model, because Mendix's expression engine rejects the `<>` spelling (CE0117). + +**An assertion the runner cannot compile is an ERROR, never a pass.** Unknown +functions, wrong arity, unbalanced parentheses and expressions that evaluate to +a value rather than a condition are all rejected by name: + +``` +ERROR a self-evident falsehood + @expect randomInt($result) = 1: randomInt() is not a Mendix expression + function at column 1 ("randomInt") +``` + +This is the one rule the annotation is built around. A test framework that +cannot evaluate an assertion has exactly one safe behaviour, and passing is not +it — an earlier version matched only `$var = ` and silently discarded +every other line, so `@expect 1 = 2` reported PASS. + +**A failure reports what came back**, not just what was wanted, whenever the +observed value's type is pinned down by the assertion itself: + +``` +FAIL the board is 81 squares + expected length($result) = 81, actual: 27 +``` + +The value is omitted rather than guessed when neither side of the comparison +establishes a type (`@expect $a = $b`), because Mendix's expression engine is +typed and a wrong guess would break the build instead of the test. + +#### `count($list)` — the one aggregate an assertion can make + +Counting a list is not a Mendix *expression* function; it is an Aggregate list +**activity**, so it cannot appear in the decision that evaluates an assertion. +`@expect count($Scans) = 2` is nevertheless accepted: the count is lifted into +the activity you would otherwise write by hand, ahead of the decision, and the +condition compares its result. + +```mdl +/** + * @test the seed microflow writes five brands + * @cleanup none + * @expect count($Brands) = 5 + */ +retrieve $Brands from eShop.CatalogBrand; +/ +``` + +The other four aggregates (`sum`, `average`, `minimum`, `maximum`) aggregate an +**attribute** over the list, which an assertion has no way to supply, so they are +refused with that explanation. Call a microflow that returns the figure and +assert on its result: + +```mdl +$Total = call microflow eShop.QRY_OrderTotal(); +``` + +The refusal matters more than the convenience: before it, a count assertion was +dropped during parsing, and a test with no assertions left passes as long as its +body does not throw — so `@expect count($Brands) = 999` reported PASS against an +empty table (#927). + +### `@setup` — the state a test needs before it runs + +`@setup` names a **microflow** to call before the test's own statements. A +fixture in a Mendix app is a microflow, so there is nothing to declare: + +```mdl +/** + * @test the seed microflow writes five brands + * @setup eShop.ACT_SeedCatalog + * @cleanup none + * @expect count($Brands) = 5 + */ +retrieve $Brands from eShop.CatalogBrand; +/ +``` + +Repeat it to compose fixtures; they run in the order written. Declare it **once +in the file's header comment** and every test in the file gets it, with the +file's fixtures running before a test's own: + +```mdl +/** + * Seeds every test below. + * @setup eShop.ACT_SeedCatalog + */ +``` + +The header is the file's first doc comment when it carries no `@test`. It may +only carry `@setup` — `@expect`, `@verify`, `@throws` and `@cleanup` describe one +test's execution, so a header carrying one is refused by name rather than +silently ignored. + +Two consequences worth knowing: + +- **The setup runs inside the test's transaction.** Under the `@cleanup + rollback` default it is undone with the test, so every test starts from the + same state — which is what makes a fixture worth having. Under `@cleanup none` + it persists like everything else that test writes. +- **A failing setup is an ERROR, not a FAIL**, naming the microflow. The test + never ran, so it neither passed nor failed, and a suite full of assertion + mismatches caused by one broken seed is exactly what this prevents. + +`@setup` calls a microflow with no arguments; a fixture that needs arguments +gets a wrapper microflow. There is no `@teardown` — `@cleanup rollback` is the +teardown. + +### `@verify` — asserting on what the microflow wrote + +`@expect` can only see what a microflow **returned**. Most Mendix microflows are +side effects, so `@verify` is how you assert on the rows one left behind: an OQL +query, a comparison, and the value it must satisfy. + +```mdl +/** + * @test dealing a board writes 81 cells + * @cleanup none + * @expect $result = 'ok' + * @verify select count(*) as n from Sudoku.Cell = 81 + * @verify select count(*) as n from Sudoku.Cell where Value = 0 > 0 + */ +$result = CALL MICROFLOW Sudoku.ACT_DealGame(); +/ +``` + +The query runs against the app **after** the microflow returns, over the same +admin API `mxcli oql` uses. Three rules follow from that, and each is enforced +rather than left to trip you up: + +- **`@cleanup none` is required.** `rollback` is the default, and it undoes the + test's writes before the query could see them — so a `@verify` on a rollback + test is **refused**, not run against the pre-test state. +- **The query must return exactly one row and one column.** Comparing a table to + a literal would mean guessing which cell was meant. Aggregate it + (`select count(*)`), or select one attribute of one row. +- **The expected value is a literal** — a number, a quoted string, `true`/`false` + or `empty`. It is split off at the **last** comparison operator outside quotes + and parentheses, so a `where Value = 5` in the query is left alone. +- **Every selected column needs a name.** Mendix's OQL rejects a bare + `select count(*)` with *"All OQL select columns must have a name"*, so write + `select count(*) as n`. That comes back as an ERROR, not a pass. + +Operators: `=`, `!=` (`<>` accepted), `<`, `<=`, `>`, `>=`. Numbers compare +numerically even though the runtime returns them as strings. + +**A `@verify` that cannot be evaluated is an ERROR, never a pass** — an unknown +entity, malformed OQL, a non-scalar result, or something that was never a query: + +``` +ERROR dealing a board writes 81 cells + @verify select count(*) as n from Sudoku.NoSuch = 1: OQL error: Unknown entity +``` + +and a false one fails with the value that came back: + +``` +FAIL dealing a board writes 81 cells + expected select count(*) as n from Sudoku.Cell = 81, actual: 27 +``` + +`@verify` needs the test endpoint, so it runs under `--local` (the default) and +`--attach`. The Docker / `--legacy-runner` path **refuses** a suite using it: +its tests execute during boot, so there is no point at which to query the app. + +### A test that asserts nothing says so + +Every result line carries what the test actually checked, and a run that +contains a vacuous test calls it out: + +``` + PASS the board is 81 squares (6ms, 2 assertions) + PASS asserts nothing at all (4ms, no assertions) +------------------------------------------------------------ +1 test(s) asserted nothing beyond "did not throw". Run with +--require-assertions to make that an error. +``` + +A test with no `@expect` and no `@throws` is a **smoke test** — it reports only +that the body did not throw. That is a legitimate thing to write, so it still +passes by default. What it may not do is look identical to a test with six +assertions: after `@expect` started failing closed, the cheapest way back to a +green suite is to delete the assertion, and that must not read as a repair. + +`--require-assertions` turns every vacuous test into an ERROR, for a project +that has decided each test must assert. The JUnit report carries the count as a +`` per case, and `classname` now identifies the +source file, so a failure in a multi-file run says where it lives. + +### `@cleanup` — what happens to a test's data + +**`rollback` is the default**, so by default a test's database writes do not +survive it. The endpoint opens a transaction around the call and rolls it back +afterwards, including when the test throws. + +```mdl +/** + * @test creating an order does not leak + * @expect $result = 'ok' + */ +$result = CALL MICROFLOW Sales.CreateOrder(Amount = 100); +/ + +/** + * @test seed data the next test needs + * @cleanup none + */ +$result = CALL MICROFLOW Sales.SeedCatalogue(); +/ +``` + +Use `@cleanup none` when the writes are the point — seeding a fixture, or +inspecting the result in the running app afterwards. + +Two things worth knowing: + +- **`--local` only.** Rollback needs the test endpoint, which owns the context + the test runs in. The Docker / `--legacy-runner` path executes tests inside + the after-startup action and has no such seam, so it always commits. +- **A rollback that fails is reported, loudly.** The run prints a `WARNING` per + affected test and a summary line, because the alternative — data left behind + while the suite still says PASS — is the failure mode this annotation exists + to prevent. `--verbose` tags every test with `[rolled back]`, `[committed]` or + `[ROLLBACK FAILED]`. + +A misspelled strategy (`@cleanup rollbak`) is a **parse error**, not a silent +fallback to committing. + +Rollback matters most under `--attach`, where the database is the one your dev +app is using. + +--- diff --git a/.claude/skills/mendix/write-microflows/SKILL.md b/.claude/skills/mendix/write-microflows/SKILL.md index 04f113413..b3349e93a 100644 --- a/.claude/skills/mendix/write-microflows/SKILL.md +++ b/.claude/skills/mendix/write-microflows/SKILL.md @@ -7,6 +7,25 @@ description: "Microflow syntax reference in MDL — every activity type, control This skill provides comprehensive guidance for writing Mendix microflows in MDL (Mendix Definition Language) syntax. +## Reference files + +`SKILL.md` covers the shape of a microflow and the decisions. The detail lives +beside it, and is worth opening when you hit one of these: + +- [`reference/control-flow.md`](reference/control-flow.md) — every form of `if`, + `case`, `split type`, `loop` and `while`, plus error handling (`try`/custom + handlers) and the `@position` / `@merge` / `@anchor` activity annotations. +- [`reference/data-operations.md`](reference/data-operations.md) — create, change, + commit, delete; list operations and aggregates; `retrieve` from database, + association and list; XPath navigation. +- [`reference/integration.md`](reference/integration.md) — `rest call` and + `send rest request`, legacy SOAP, calling Java actions (including the `empty` + argument and microflow-typed parameters), and file downloads. +- [`reference/pitfalls.md`](reference/pitfalls.md) — **read this when + `mxcli check` rejects something you believe is correct.** The anti-patterns, the + CE0111 duplicate-variable trap, the syntax that looks plausible and does not + parse, and the Studio Pro errors each one produces. + ## When to Use This Skill Use this skill when: @@ -228,780 +247,6 @@ declare $Counter = 0; -- Type inference not always supported declare $list list of Test.Product; -- Case sensitive ``` -## Common Pitfalls - -### 1. Object (Entity) Variables Cannot Be Declared - -**Error**: CE0053 - "Selected type is not allowed" (+ CE0038, CE7247) — passes -`mxcli check` only until MDL043 was added; previously surfaced at mxbuild. - -❌ **INCORRECT:** -```mdl -declare $Product Test.Product; -- object Create Variable — rejected -declare $Product Test.Product = $In; -- aliasing a parameter — rejected -declare $Product as Test.Product; -- AS keyword also not supported -``` - -✅ **CORRECT** — get the object from a source that produces one: -```mdl --- as a microflow parameter -create microflow Test.Save ($Product: Test.Product) returns boolean as $ok ... - --- from a retrieve (single object) -retrieve $Product from Test.Product where Code = $Code limit 1; - --- from a create object -$Product = create Test.Product (Name = $Name); - --- from a loop iterator -loop $Product in $Products -begin - change $Product (Processed = true); -end -``` - -**Explanation**: `declare` becomes a *Create Variable* activity, which in Mendix -only holds primitive types. Objects (and lists) have no Create Variable form — use -the parameter/retrieve/create/loop sources above. To "keep a reference" to an -existing object, just use the variable you already have (`$In`, the loop variable, -the retrieve/create output); Mendix has no aliasing activity. - -### 2. XPath Association Navigation - -**Error**: CE0117 - "Error in expression" - -❌ **INCORRECT:** -```mdl --- Using simple association name -declare $CustomerName string = $Order/Customer/Name; -set $Name = $Product/Category/Name; -``` - -✅ **CORRECT:** -```mdl --- Use fully qualified association name: Module.AssociationName -declare $CustomerName string = $Order/Shop.Order_Customer/Name; -set $Name = $Product/Shop.Product_Category/Name; -``` - -**Explanation**: XPath navigation requires the full qualified association name in the format `Module.AssociationName`. - -### 3. Missing Attributes - -**Error**: Attribute references must exist in entity definition - -❌ **INCORRECT:** -```mdl --- Referencing Status when it doesn't exist in Order entity -change $Order ( - status = 'PROCESSING', - ProcessedDate = [%CurrentDateTime%]); -``` - -✅ **CORRECT:** -```mdl --- First, ensure entity has the attributes -create persistent entity Shop.Order ( - OrderNumber: string(50), - status: string(50), -- ← Must be defined - ProcessedDate: datetime -- ← Must be defined -); - --- Then reference them -change $Order ( - status = 'PROCESSING', - ProcessedDate = [%CurrentDateTime%]); -``` - -### 4. Flow Must End with RETURN - -**Error**: CE0105 - "Activity cannot be the last object of a flow" - -❌ **INCORRECT:** -```mdl -begin - declare $success boolean = true; - log info 'Done'; - -- Missing RETURN! -end; -``` - -✅ **CORRECT:** -```mdl -begin - declare $success boolean = true; - log info 'Done'; - return $success; -- ← Always required -end; -``` - -### 5. Unreachable Code After RETURN - -**Error**: CE0104 - "Action activity is unreachable" - -❌ **INCORRECT:** -```mdl -if $value < 0 then - return false; - log info 'This will never execute'; -- ← Unreachable! -end if; -``` - -✅ **CORRECT:** -```mdl -if $value < 0 then - log info 'Value is negative'; - return false; -end if; -``` - -### 6. Unused Variables - -**Warning**: CW0094 - "Variable 'X' is never used" - -```mdl --- Studio Pro will warn if parameters/variables are declared but never used -create microflow Test.Example ( - $ProductCode: string -- ← Warning if never referenced -) -returns boolean as $success -begin - set $success = true; -- ProductCode never used - return $success; -end; -``` - -### 7. Using SET on Undeclared Variables - -**Error**: MDL executor validates that all variables used with `set` are declared first. - -❌ **INCORRECT:** -```mdl -begin - if $value > 10 then - set $message = 'High'; -- ERROR: $Message not declared! - end if; - return true; -end; -``` - -✅ **CORRECT:** -```mdl -begin - declare $message string = ''; -- Declare first - if $value > 10 then - set $message = 'High'; -- Now SET works - end if; - return true; -end; -``` - -**Note**: Parameters are automatically declared by the parameter list. The `returns type as $Var` syntax names the return variable but does NOT declare it - you must still use `declare $Var type = value;` if you want to use SET on it. - -### 8. RETURN Inside a Loop - -**Error**: CE0068 - "End events cannot be placed inside a loop." (MDL062) - -A `return` builds an End event, and Mendix does not allow one inside a loop — -whether the return sits in the loop body directly or inside a branch within it. - -❌ **INCORRECT:** -```mdl -loop $Part in $PartList -begin - if $Part/IsMatch then - return true; -- End event inside the loop - end if; -end loop; -``` - -✅ **CORRECT** — leave the loop with `break`, and return once after it: -```mdl -declare $Found boolean = false; -loop $Part in $PartList -begin - if $Part/IsMatch then - set $Found = true; - break; - end if; -end loop; -return $Found; -``` - -### 9. Two Activities Creating the Same Variable - -**Error**: CE0111 - "Duplicate variable name 'X'." (MDL063) - -A microflow's variable names are unique **flow-wide**. Branches and loop bodies -do not open a scope, and parameters and loop iterators share the same namespace. -The trap is that every activity with an output **creates** its variable — there -is no form in which a call, a retrieve, an aggregate or an import mapping writes -into one that already exists. - -❌ **INCORRECT:** -```mdl -declare $Session string = ''; -$Session = call microflow Mod.Login(); -- the call creates $Session too -``` - -✅ **CORRECT** — let the activity create it: -```mdl -$Session = call microflow Mod.Login(); -``` - -Assigning to an existing variable is fine, because `set` is a *Change Variable* -activity and creates nothing: - -```mdl -declare $Session string = ''; -set $Session = 'anonymous'; -- valid, any number of times -``` - -### 10. Calling a Rule or Microflow Inside an Expression - -**Error**: CE0117 - "Error(s) in expression." (MDL066) - -A Mendix **expression** has no user-callable functions. Its library is built-in -and unqualified (`length`, `toString`, `contains`, ...); microflows, rules and -Java actions are called by **activities**. So a qualified call in a value -position is not an expression at all — mxbuild rejects it whichever document it -names. - -❌ **INCORRECT:** -```mdl -declare $Active Boolean = Sample.Rule_IsActive(IsActive = $IsActive); -declare $Next Integer = Sample.MF_Increment(N = $N); -- a microflow is no better -``` - -✅ **CORRECT** — a microflow or Java action is an activity: -```mdl -$Next = call microflow Sample.MF_Increment(N = $N); -``` - -A **rule** has no call activity at all: Mendix can only evaluate one as a -decision's condition, and that is the single position where a bare qualified -call is valid MDL. - -```mdl -if Sample.Rule_IsActive(IsActive = $IsActive) then - ... -end if; -``` - -The name in that position must resolve to a real **rule** — a microflow there is -the same CE0117, and mxcli refuses the statement rather than writing it. - -## Control Flow - -### IF Statements - -```mdl --- Simple IF -if $value > 10 then - set $message = 'Greater than 10'; -end if; - --- IF/ELSE -if $value > 100 then - set $Category = 'High'; -else - set $Category = 'Low'; -end if; - --- Nested IF -if $Score >= 90 then - set $Grade = 'A'; -else - if $Score >= 80 then - set $Grade = 'B'; - else - set $Grade = 'C'; - end if; -end if; -``` - -**Important**: Always close with `end if` (not just `end`). - -### Enumeration Comparisons - -**CRITICAL**: When comparing enumeration values, use the fully qualified enumeration value, NOT a string literal. - -```mdl --- CORRECT: Use fully qualified enumeration value -if $task/status = Module.TaskStatus.Completed then - set $IsComplete = true; -end if; - -if $Order/OrderStatus != Module.OrderStatus.Cancelled then - -- Process the order -end if; - --- WRONG: Do NOT use string literals --- IF $Task/Status = 'Completed' THEN -- INCORRECT! -``` - -Putting an enumeration **into** a string is the same mistake — concatenating it -directly is rejected, so render it first with `getCaption()` (the caption) or -`toString()` (the value name): - -```mdl --- CORRECT -log warning 'Unexpected status: ' + getCaption($Order/Status); - --- WRONG -log warning 'Unexpected status: ' + $Order/Status; -``` - -**Where the string form is and is not accepted** (verified against mxbuild 11.13.0 — -one microflow per row, `mx check` read per construct): - -| Context | `'Draft'` | Note | -|---------|-----------|------| -| Comparison in a decision — `if $O/Status = 'Draft'` | ❌ **CE0117** | The one that bites | -| Concatenation — `'x' + $O/Status` | ❌ **CE0117** | Use `getCaption()` / `toString()` | -| `change $O (Status = 'Draft')` | ✅ accepted | Slot is already enum-typed | -| `create M.E (Status = 'Draft')` | ✅ accepted | Same | -| Attribute `DEFAULT 'Draft'` | ✅ accepted | Documented as the legacy form | -| XPath constraint `[Status = 'Draft']` | ✅ accepted | Enums are strings at DB level | - -`mxcli check` does **not** flag the two failing rows (it does not type expressions — -see `docs/11-proposals/PROPOSAL_expression_type_checking.md`), so a script can pass -`check` and fail the build. The qualified form is valid in every row above: use it -everywhere and the distinction never has to be remembered. - -**Checking for empty enumeration:** -```mdl -if $entity/status = empty then - -- Enumeration is not set -end if; -``` - -### CASE Statements (Enum Split) - -Use `case` when a microflow branches on an enumeration value. - -```mdl -case $Status - when Open, Pending then - return true; - when Closed then - return false; - when (empty) then - return false; -end case; -``` - -`(empty)` represents an unset enumeration value. Multiple values can share one `when` branch by separating them with commas. Case values are bare identifiers — do **not** quote them. - -> **Every value needs a branch, including `(empty)` — and there is no `else`.** -> A Mendix enum split is an exclusive split with one outgoing flow per condition -> value, so an uncovered value fails the build with **CE0079** *"The 'X' condition -> value should be configured in properties for an outgoing flow."* `mxcli check` -> reports a missing `(empty)` branch as **MDL056**, and an `else` branch as -> **MDL008** (an `else` does not stand in for the missing flows: mxbuild reports -> CE0079 for each uncovered value *and* CE0773 on the else flow itself). -> -> The `(empty)` branch is required **even when the attribute is `not null`** — -> verified on Mendix 11.6.6. If several values share a path, put them in one -> branch (`when Open, Pending then`) rather than reaching for `else`. - -### Type Split And Cast Statements - -Use `split type` when a microflow branches on an object's runtime specialization. -Use `cast` inside a type branch to create the specialized variable used by the branch body. - -Branches are `when then`, the same as an enumeration split — one -statement, two subjects. - -```mdl -declare $IsSpecialized boolean = false; -split type $Input - when Sample.SpecializedInput then - cast $SpecificInput; - set $IsSpecialized = true; - when Sample.BaseInput then - when (empty) then -end split; -return $IsSpecialized; -``` - -Branch values are qualified entity names. - -> **`when (empty) then` is the null-object branch, not a default.** It is -> Mendix's `(empty)` outgoing flow, taken when the split variable is empty. It -> does **not** cover types you did not name — see the CE0090 note below. -> -> **The older spelling still works.** `case Sample.SpecializedInput` (no `then`) -> and `else` for the empty branch parse and build the identical flow, but warn -> **MDL065**. `case` introduced a *branch* here while introducing the *subject* -> in `case $x when V then` and in expressions, so the word meant two things -> (mxcli #913); `else` read as a default and never was one. - -> **Every type needs a branch — including the base entity.** An object-type -> decision gets one outgoing flow per listed type, and a type with no flow fails -> the build with **CE0090** *"The 'X' value should be configured for an outgoing -> flow."* The base entity (the split variable's own type) counts: `case -> Sample.BaseInput` above is what covers "it is not any of the specializations". -> -> **The `(empty)` branch does not stand in for the base-type case.** It is -> accepted — it serializes as `Microflows$NoCase` — but it does not satisfy -> coverage, so one named type plus `when (empty) then` still fails CE0090. -> Measured on 11.13.0: `when Zoo.Dog then` + an empty branch gives CE0090 for -> `Zoo.Cat` **and** `Zoo.Animal`; branch on every type, keep the empty branch, -> and it is 0 errors. Verified on Mendix 11.6.6 and 11.13.0. -> -> You cannot drop the empty branch either — that is **CE0089**. mxcli emits the -> flow unconditionally for that reason. -> -> **The split needs somewhere to go afterwards.** Branch bodies converge on a -> merge that continues to the microflow's end event, so a non-void microflow -> needs a `return` after `end split;` — otherwise `mxcli check` reports MDL003 -> and the build fails **CE0067** *"The 'Return value' property is required."* -> Doing the per-branch work into a variable and returning it once (above) is the -> clearest shape; returning inside every branch also works, but still needs the -> trailing `return`. - -**`cast` only stores the output variable.** Studio Pro persists Microflows$CastAction with a single `VariableName` field — the source variable is implicit (the type-split's input). Use `cast $SpecificName;` to give the specialized variable its name. The two-variable form `$Output = cast $Source;` parses but `$Source` is dropped on roundtrip; prefer the single-variable form. - -### LOOP Statements - -```mdl --- Basic loop -loop $Product in $ProductList -begin - set $count = $count + 1; -end loop; - --- Loop with object modification -loop $Product in $ProductList -begin - change $Product (IsActive = true); - commit $Product; -end loop; - --- Loop with conditional logic -loop $Product in $ProductList -begin - if $Product/IsActive then - set $ActiveCount = $ActiveCount + 1; - end if; -end loop; - --- Label a loop with a note (loops have no caption in Mendix) -@annotation 'Process each product' -loop $Product in $ProductList -begin - set $count = $count + 1; -end loop; -``` - -> **`@caption` does nothing on a loop.** Mendix for-loops have no caption -> property, so `@caption` on a `loop` is silently dropped (`mxcli check` flags -> it as **MDL042**). To label a loop, use `@annotation 'text'` — it attaches a -> note, exactly like drawing one onto the loop in Studio Pro. - -**Note**: -- Loop variable (`$Product`) is scoped to the loop body -- The loop variable type is **automatically derived** from the list type (e.g., `list of Test.Product` → `Test.Product`) -- CHANGE statements inside loops use the derived type to resolve attribute names - -> **Nothing a loop defines survives past `end loop;`.** The iterator *and* -> anything the body creates (a `retrieve`, a `$X = create …`, a call output) are -> visible only inside the body; using one afterwards is -> `CE0108 "Variable 'X' is defined but not in scope at this location."` -> (`mxcli check` flags it as **MDL053**). -> -> ```mdl -> -- WRONG: $Last is created inside the loop, read outside it -> loop $Item in $Items -> begin -> $Last = create Test.Product (Name = $Item/Name); -> end loop; -> commit $Last; -- MDL053 / CE0108 -> -> -- RIGHT: declare before the loop, assign inside, read after -> declare $LastName string = ''; -> loop $Item in $Items -> begin -> set $LastName = $Item/Name; -> end loop; -> log info node 'Test' $LastName; -> ``` -> -> Visibility and *naming* are separate rules: names must also be unique across -> the **whole** microflow, so two loops cannot share an iterator name either -> (`CE0111`, flagged as **MDL052**). - -### Performance: Batch Commit After Loop - -**CRITICAL**: Do NOT commit inside a loop. Each `commit` inside a loop issues a separate database transaction, which causes N round-trips for N records and degrades performance significantly. - -❌ **INCORRECT — commit inside loop (N transactions):** -```mdl -loop $Binding in $BindingsList -begin - $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); - commit $NewBatch with events; -- ❌ one DB transaction per record -end loop; -``` - -✅ **CORRECT — create list before loop, commit once after:** -```mdl -$BatchList = create list of BatteryOntology.MaterialBatch; -loop $Binding in $BindingsList -begin - $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); - add $NewBatch to $BatchList; -- accumulate in memory -end loop; -commit $BatchList with events on error rollback; -- ✅ single transaction -``` - -**Pattern:** -1. Before the loop: `$XxxList = create list of Module.Entity;` -2. Inside the loop: `add $NewXxx to $XxxList;` (replaces `commit`) -3. After the loop: `commit $XxxList with events on error rollback;` - -This applies whenever the loop **creates** new objects. For loops that only **change** existing objects, the same pattern applies — accumulate changed objects in a list, commit the list once outside the loop. - - -## Object Operations - -### CREATE Object - -```mdl -$NewProduct = create Test.Product ( - Name = $Name, - Code = $Code, - IsActive = true, - CreateDate = [%CurrentDateTime%]); -``` - -**Syntax Rules:** -- Variable assignment on left side (`$NewProduct =`) -- Entity type is fully qualified -- Attributes in parentheses, comma separated -- Closing `)` followed by semicolon -- Syntax aligned with CALL MICROFLOW/CALL JAVA ACTION - -**The Commit flag** (Studio Pro's "Commit" dropdown) is the optional `commit` -modifier after the member list: - -```mdl -$Order = create Sales.Order (Number = $Nr); -- Commit: No (default) -$Order = create Sales.Order (Number = $Nr) commit; -- Commit: Yes -$Order = create Sales.Order (Number = $Nr) commit without events;-- Commit: YesWithoutEvents -``` - -Omit it for the default. This is a **modifier on the create**, not the separate -`commit $Var;` activity — see COMMIT Object below for that one. - -### CHANGE Object - -```mdl -change $Product ( - Name = $NewName, - ModifiedDate = [%CurrentDateTime%]); - --- Commit the changed object as part of the change activity -change $Product (Name = $NewName) commit; -change $Product (Name = $NewName) commit without events; - --- Refresh the changed object in the client -change $Product (Name = $NewName) refresh; - --- Both: commit comes first -change $Product (Name = $NewName) commit refresh; -``` - -**Note**: Only specify attributes you want to change. Syntax aligned with CREATE. - -### COMMIT Object - -```mdl --- Commit without events -commit $Product; - --- Commit with events (triggers event handlers) -commit $Product with events; - --- Commit with refresh in client (updates UI after commit) -commit $Product refresh; - --- Commit with events and refresh -commit $Product with events refresh; -``` - -**Best Practice**: Use `with events` when you want before/after commit event handlers to execute. Use `refresh` when the committed object is displayed in the client and you want the UI to update immediately. - -> **Re-sorting a database-datasource grid needs `refresh`.** A plain `commit $Obj;` updates the committed attribute *values* in the grid, but a grid backed by a **database** datasource does **not** re-run its sort — so after changing a sort key (e.g. a reorder that rewrites a `SequenceNumber`), the row stays in its old position until you `commit $Obj refresh;`. The `refresh` re-queries the datasource, which re-applies the sort. (Ledger #57.) - -> **Binding a microflow to an entity event is MDL — you do NOT need to map it manually in Studio Pro.** After writing a handler microflow (e.g. a `BeforeCommit` validation), wire it directly: -> ```mdl -> alter entity Sales.Order -> add event handler on before commit call Sales.ACT_ValidateOrder($currentObject) raise error; -> ``` -> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model](../generate-domain-model/SKILL.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) - -## List Operations - -```mdl --- Existing variable form -add $Item to $Items; - --- Expression-valued add, useful when round-tripping Studio Pro list-add values -add head($SourceItems) to $Items; -``` - -Use expression-valued `add` only when the expression returns an object compatible with the target list element type. - -### `contains` is overloaded — string vs list - -`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically: - -```mdl --- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action) -declare $HasAt Boolean = false; -set $HasAt = contains($Email, '@'); - --- LIST contains — do NOT pre-declare the output (the list op creates it) -set $Found = contains($Items, $Item); -``` - -The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. - -## Database Operations - -### RETRIEVE Statement - -```mdl --- Retrieve all -retrieve $ProductList from Test.Product; - --- Retrieve with WHERE -retrieve $ProductList from Test.Product - where Code = $SearchCode; - --- Retrieve with multiple conditions -retrieve $ProductList from Test.Product - where IsActive = true - and Price > 100; - --- Retrieve single object -retrieve $Product from Test.Product - where Code = $ProductCode; -``` - -**Important**: -- Use `from Module.Entity` (fully qualified) -- RETRIEVE with `limit 1` returns a **single entity** -- RETRIEVE without `limit 1` returns a **list** (`list of Module.Entity`) -- Use `limit 1` when you expect exactly one result (e.g., lookup by unique key) - -**Sorting and paging** — use `sort by`, **not** `order by`: - -```mdl -retrieve $Recent from Sales.Order - where Status = Sales.OrderStatus.Open - sort by Sales.Order.OrderDate desc, Sales.Order.OrderNumber asc - limit $PageSize - offset $Offset; -``` - -- The keyword is **`sort by`** (one or more `Module.Entity.Attr asc|desc`, comma-separated). `order by` is **not** valid on a microflow `retrieve` — it's reserved for `select ... from CATALOG.*` queries and will cause a parse error here. -- `limit` and `offset` accept **a variable or expression**, not only a literal — `limit $PageSize`, `offset $Offset`, even `limit $Base + 5` all work. A bare literal (`limit 20`) is just the simplest case. - -### Retrieve by Association (in-memory, over an association path) - -To get the object(s) related to one you already have, retrieve **over an -association** — `retrieve $out from $source/Module.Association;`. This is an -*association* (in-memory) retrieve, not a database query, so it has no `where` / -`sort by` / `limit`. **The result type depends on the direction you navigate:** - -```mdl --- FORWARD (from the reference-owner / "one" side) → a SINGLE object. --- An Expense has one Employee (Expense_Employee: from Expense to Employee): -retrieve $Employee from $Expense/MyFirstModule.Expense_Employee; -change $Employee (Name = 'Updated'); -- change it directly — do NOT loop - --- REVERSE (from the "many" side) → a LIST of the related objects. --- One Employee has many Expenses (same association, navigated the other way): -retrieve $Expenses from $Employee/MyFirstModule.Expense_Employee; -loop $Expense in $Expenses -begin - change $Expense (Amount = 0); -end loop; -``` - -- **Forward Reference traversal returns a single object** — do **not** `loop` over - it. Looping a single object passes `mxcli check` but produces an invalid project - (mxbuild `StorageLoadException` — the loop's `change` writes an unqualified - attribute). Loop only over the list-valued (reverse / ReferenceSet) form. -- The association is always fully qualified (`Module.Association`), and the start - variable is an object you already have (a parameter, a prior retrieve/create, or - a loop iterator). Both forms above are mxbuild-verified (0 errors). - -**Enumeration attributes in WHERE**: XPath is a database query, so enum values are stored as plain strings. Both forms are valid — mxcli converts the qualified name to a string literal in BSON: - -```mdl --- Preferred: qualified name (mxcli converts to 'Open' in BSON) -retrieve $Open from Module.Order - where [Status = Module.OrderStatus.Open]; - --- Also accepted: string literal (the value key, case-sensitive) -retrieve $Open from Module.Order - where [Status = 'Open']; - --- Multiple enum values with OR -retrieve $InProgress from Module.Order - where [Status = Module.OrderStatus.Open or Status = Module.OrderStatus.Processing]; -``` - -This is different from IF/SET expressions — see "Enumeration Comparisons" section above. - -## XPath Navigation - -### Attribute Access - -```mdl --- Read attribute -declare $ProductName string = $Product/Name; -declare $Price decimal = $Product/Price; - --- Write attribute (alternative to CHANGE) -set $Product/Price = $NewPrice; -set $Product/ModifiedDate = [%CurrentDateTime%]; -``` - -### Association Navigation - -```mdl --- Navigate to related object -declare $CustomerName string = $Order/Shop.Order_Customer/Name; -declare $CategoryName string = $Product/Shop.Product_Category/Name; - --- Set association -set $Order/Shop.Order_Customer = $Customer; -set $Order/Shop.Order_Product = $Product; -``` - -**Critical**: Always use fully qualified association names (`Module.AssociationName`). - -### XPath in Expressions - -```mdl --- Use in calculations -declare $MonthlyTotal decimal = $Product/MonthlyTotal; -declare $DailyAverage decimal = $MonthlyTotal div 30; - --- Use in conditions -if $Product/IsActive then - set $count = $count + 1; -end if; - --- Combine with operators -set $TotalPrice = $Product/Price * $Quantity; -``` - ## Operators ### Arithmetic @@ -1077,44 +322,6 @@ log info node 'OrderService' 'Order processed: ' + $OrderNumber; log error node 'Service' 'Error: ' + $ErrorMessage; ``` -## Activity Annotations - -Annotations use `@` prefix syntax placed before the activity they apply to: - -```mdl --- Canvas position (always shown in DESCRIBE output) -@position(200, 200) -commit $Order with events; - --- Custom caption (overrides auto-generated caption) -@caption 'Save the order' -commit $Order with events; - --- Background color (Blue, Green, Red, Yellow, Purple, Gray) -@color Green -log info node 'App' 'Success'; - --- Visual note attached to the next activity (creates AnnotationFlow) -@annotation 'Validate the order before processing' -commit $Order with events; - --- Multiple annotations stacked on a single activity -@position(400, 200) -@caption 'Persist product' -@color Blue -@annotation 'Step 2: Save to database' -commit $Product; -``` - -**Rules:** -- `@annotation` before an activity attaches the note to that activity -- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity -- `@annotation` at the end (no following activity) creates a free-floating note -- Escape single quotes by doubling: `@annotation 'Don''t forget'` -- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default -- DESCRIBE MICROFLOW shows `@` annotations before their activities -- `@start(x, y)` positions the **start event** and goes on the first statement, because the start has no statement of its own. Omit it and the start is derived — one spacing unit (160) left of the first activity, on its centre line — and a rewrite re-derives it so the start follows the activities when they move. A start that is not at the derived spot was placed by hand (in Studio Pro or with `@start`): it survives a rewrite that does not mention it, and DESCRIBE emits `@start` for it. An explicit `@start` overrides both (#951) - ## Special Values ```mdl @@ -1265,520 +472,6 @@ close page; show home page; ``` -## Implicit Variable Creation (CE0111 Duplicate Variable) - -These statements **implicitly create a new variable** with the name on the left side: - -- `$Var = call microflow ...` -- `$Var = call java action ...` -- `$Var = call nanoflow ...` -- `$Var = create Module.Entity (...)` -- `retrieve $Var from Module.Entity ...` - -**Do NOT use `declare` before these** — it creates a duplicate variable (CE0111): - -```mdl --- WRONG: Duplicate variable — DECLARE + CALL both create $Result -declare $Result boolean = false; -$Result = call java action Module.DoSomething(); -- CE0111! - --- CORRECT: Let CALL create the variable, use a different name if you need a default -declare $success boolean = false; -$CallResult = call java action Module.DoSomething(); -set $success = $CallResult; - --- CORRECT: Simple pass-through (no default needed) -$Result = call java action Module.DoSomething(); -return $Result; -``` - -The same applies to RETRIEVE: - -```mdl --- WRONG: declaring a list at all (CE0053/CE0038, MDL040) — and duplicate variable -declare $Items list of Module.Entity = empty; -retrieve $Items from Module.Entity where Active = true; -- CE0111! - --- CORRECT: Let RETRIEVE create the variable -retrieve $Items from Module.Entity where Active = true; -``` - -**Note**: `returns type as $Var` in the microflow signature does NOT create an activity variable — it only names the return value. So `$Var = call java action ...` after `returns as $Var` is fine (one creation). - -**Variables are scoped to the branch that creates them.** A variable first created -inside an `if`/`else` arm (including by `$Var = call ...`) is not visible outside -that arm. To use one value after a conditional, `declare` it *before* the -conditional and assign in every branch: - -```mdl --- WRONG: $GTotalText is created only in the `then` arm → not declared in `else` -if $HasVariance then - $GTotalText = call microflow Module.FMT_Variance($v); -- created here only -else - set $GTotalText = 'n/a'; -- error: not declared -end if; - --- CORRECT: declare before, then set in each branch (call into a temp, then set) -declare $GTotalText string = ''; -if $HasVariance then - $Tmp = call microflow Module.FMT_Variance($v); - set $GTotalText = $Tmp; -else - set $GTotalText = 'n/a'; -end if; -``` - -**Fallback chains reuse the same name = CE0111.** Because each `$Var = call microflow …` (or retrieve/create) is a *fresh* variable creation, the natural "try A, else try B" shape is invalid — even when the retry is inside an `if`: - -```mdl --- WRONG: the second call re-creates $Summary — CE0111 -$Summary = call microflow M.Inner(Tag = 'description'); -if trim($Summary) = '' then - $Summary = call microflow M.Inner(Tag = 'summary'); -- CE0111! -end if; - --- CORRECT: one variable per call, then a plain `set` picks the winner -$Summary = call microflow M.Inner(Tag = 'description'); -if trim($Summary) = '' then - $SummaryFallback = call microflow M.Inner(Tag = 'summary'); - set $Summary = $SummaryFallback; -end if; -``` - -`mxcli check --references` catches this (CE0111) before the build. - -## Legacy SOAP Web Service Calls - -`call web service` preserves legacy Mendix SOAP activities. Prefer REST clients -for new integrations; this syntax exists mainly so existing projects can -round-trip without dropping SOAP actions. - -```mdl --- Structured form. Resolved SOAP references use normal qualified names. -$Root = call web service SampleSOAP.OrderService -operation FetchSampleItems -send mapping SampleSOAP.OrderRequest -receive mapping SampleSOAP.OrderResponse -timeout 30 -on error rollback; - --- Quoted raw IDs are accepted when old project references are dangling or unavailable. -$Root = call web service 'sample-service-id' -operation FetchSampleItems -send mapping 'sample-send-mapping-id' -receive mapping 'sample-receive-mapping-id'; - --- Raw escape hatch emitted for unsupported SOAP fields. -$Root = call web service raw 'AQID'; -``` - -**Design note:** the raw payload is base64-encoded BSON for the complete action -and is authoritative on re-exec. Treat this as round-trip support, not a -recommended authoring format for new integrations. - -## REST Service Calls - -MDL supports two patterns for calling REST APIs from microflows: - -### SEND REST REQUEST — Consumed REST Service Operations - -Calls an operation defined in a consumed REST service (created via `create rest client`). The URL, headers, authentication, and response mapping are configured in the REST client document — the microflow only references the operation. - -```mdl --- Fire and forget (RESPONSE NONE operation) -send rest request Module.ServiceName.OperationName; - --- With output variable (RESPONSE JSON operation — maps to entity) -$Result = send rest request Module.ServiceName.OperationName; - --- With request body (POST/PUT operations) -$Result = send rest request Module.ServiceName.CreateItem - body $NewItem; -``` - -**CRITICAL: `$latestHttpResponse` system variable** - -After every `send rest request`, Mendix automatically populates `$latestHttpResponse` (type `System.HttpResponse`). Use this to check call success — do **NOT** check the output variable directly: - -```mdl --- ✅ CORRECT: check $latestHttpResponse -$RootResult = send rest request Module.Service.GetData; -if $latestHttpResponse/Content != empty then - -- Process $RootResult (the mapped entity) -end if; - --- ❌ WRONG: checking the output variable directly causes CE0117 -if $RootResult != empty then -- ERROR! -``` - -**Key attributes on `$latestHttpResponse`:** -- `Content` (String) — response body as string. **Capital `C`** — it is inherited from the parent entity `System.HttpMessage`, so `DESCRIBE ENTITY System.HttpResponse` does not list it. Lowercase `$latestHttpResponse/content` fails CE0117. -- `StatusCode` (Integer) — HTTP status code (200, 404, etc.) - -**Restrictions:** -- `send rest request` does **NOT** support custom error handling (`on error continue/rollback` causes CE6035). Errors are always handled by aborting. -- The operation must be defined via `create rest client` with a three-part qualified name: `Module.ServiceDocument.OperationName`. - -### REST CALL — Inline HTTP Calls - -Direct HTTP call with URL, headers, auth, body, and response handling specified inline. Useful for one-off calls or when no REST client document exists. - -```mdl --- Simple GET returning string -$response = rest call get 'https://api.example.com/data' - header Accept = 'application/json' - timeout 30 - returns string; - --- POST with JSON body -$response = rest call post 'https://api.example.com/items' - header 'Content-Type' = 'application/json' - header Accept = 'application/json' - body '{{"name": "{1}", "value": {2}}' with ( - {1} = $ItemName, - {2} = toString($ItemValue) - ) - timeout 30 - returns string - on error continue; - --- POST a BINARY body (upload a file document's contents) --- The expression is the FileDocument's Contents MEMBER, not the document --- itself, and the content type goes on a header. A consumed REST CLIENT --- document has no binary body — `Body: file from $Doc` there is refused as --- MDL-REST02 — so binary uploads belong here. -$response = rest call post 'https://api.example.com/upload' - header 'ContentType' = 'application/pdf' - body binary $Doc/Contents - timeout 300 - returns response; - --- GET with URL template parameters -$response = rest call get 'https://api.example.com/users/{1}' with ( - {1} = toString($UserId) -) - header Accept = 'application/json' - returns string; - --- With basic authentication -$response = rest call get 'https://api.example.com/secure' - header Accept = 'application/json' - auth basic $username password $password - timeout 30 - returns string; - --- DELETE (no response) -rest call delete 'https://api.example.com/items/{1}' with ( - {1} = $ItemId -) - returns nothing - on error continue; -``` - -**REST CALL response types:** -- `returns string` — response body as string variable -- `returns nothing` / `returns none` — ignore response -- `returns response` — returns `System.HttpResponse` object -- `returns mapping Module.ImportMapping as Module.Entity` — single object result -- `returns mapping Module.ImportMapping as list of Module.Entity` — list result -- `returns Module.MyFile` — store the body in a **file document** - -**The file document form takes a specialization, never `System.FileDocument` -itself.** Mendix rejects the base type as a return type with `CE0362`, and -MDL064 reports it before the write. Create one first: - -```mdl -create persistent entity MyModule.MyFile extends System.FileDocument (); - -create microflow MyModule.ACT_Download ($Location: String) -begin - $file = rest call get '{1}' with ({1} = $Location) - header 'Accept' = 'application/octet-stream' - timeout 300 - returns MyModule.MyFile; -end; -``` - -There is **no** equivalent for an HttpResponse specialization: Mendix allows only -`User`, `FileDocument`, `Image` and `Paging` to be specialized (`CE1540`), so -`returns response` already names the only type that result can have. - -**Pick `as` vs `as list of` based on the call site, not the mapping shape.** The same import mapping can yield either a single object or a list — Studio Pro stores the cardinality on the microflow's `ImportMappingCall` (`Range.SingleObject` + `ForceSingleOccurrence`). Use `as Module.Entity` when the response is a single object (the mapping may still be list-typed; Studio Pro binds the first item). Use `as list of Module.Entity` when the response should bind a list. Mismatching the cardinality with the surrounding code produces `mx check` `CE0117` at the End event or `CE0013` / `CE0100` on downstream loop / aggregate / list-operation activities. - -**REST CALL supports full error handling** (`on error continue`, `on error rollback`, custom error handlers). - -## File Downloads - -Use `download file` to stream a `System.FileDocument` from a microflow. Add -`show in browser` when the action should open the file inline instead of forcing -a download. - -```mdl -download file $GeneratedReport show in browser; -download file $GeneratedExport; -``` - -## Empty Java-Action Argument (`empty`) - -When `describe` round-trips a Java-action call that has an unbound parameter -in Studio Pro, it emits `empty` as the argument value. In this Java-action -argument context, `empty` preserves the -underlying empty `BasicCodeActionParameterValue.Argument` so that the next -`describe → exec → describe` cycle stays symmetric. - -```mdl -$Total = call java action SampleModule.Recalculate( - CompanyId = empty, - RecalculateAll = true, - ItemList = empty -); -``` - -New scripts should bind every parameter to a real expression. Use `empty` -for a Java-action argument only when regenerating MDL from an existing project -that already had an unbound parameter. - -## Microflow-Typed Java-Action Parameters - -Some Java actions take a **microflow** — a callback the action invokes later. -`MCPServer.AddTool` (`ExecutingMicroflow`) and `MCPServer.CreateMCPServer` -(`AuthenticationMicroflow`) are the ones you meet first. Pass the microflow's -qualified name as a quoted string; mxcli resolves the parameter's declared type -from the Java action and stores a microflow reference, not a string literal. - -```mdl -$Tool = call java action MCPServer.AddTool( - McpServer = $Server, - Name = 'memory_add', - Description = 'Stores a memory', - ExecutingMicroflow = 'MyModule.MF_MemoryAdd', - Schema = '' -); -``` - -`DESCRIBE JAVA ACTION` prints such a parameter's type as the bare word -`Microflow` (`Nanoflow` for JavaScript actions), and that spelling is what -`CREATE JAVA ACTION` accepts, so the round-trip is stable. - -## Error Handling - -MDL supports error handling for activities that may fail (microflow calls, commits, external service calls, etc.). - -### Error Handling Types - -```mdl --- ON ERROR CONTINUE: Ignore error and continue execution -call microflow Module.RiskyOperation() on error continue; - --- ON ERROR ROLLBACK: Rollback transaction and propagate error -commit $Order with events on error rollback; - --- ON ERROR { ... }: Custom error handler with rollback -$Result = call microflow Module.ExternalService(data = $data) on error { - log error node 'ServiceError' 'External service failed'; - return $DefaultResult; -}; - --- ON ERROR WITHOUT ROLLBACK { ... }: Custom handler, keep changes -commit $Order on error without rollback { - log warning node 'CommitError' 'Commit failed, using fallback'; - change $Order (status = 'PENDING'); -}; -``` - -### Error Handling Semantics - -| Syntax | Behavior | -|--------|----------| -| `on error continue` | Catch error silently, continue normal flow | -| `on error rollback` | Rollback database changes, propagate error | -| `on error { ... }` | Execute handler block, then continue (with rollback) | -| `on error without rollback { ... }` | Execute handler block, keep database changes | - -### When to Use Each Type - -- **CONTINUE**: Non-critical operations where failure is acceptable -- **ROLLBACK**: Critical operations where data integrity must be preserved -- **Custom handlers**: When you need to log errors, set fallback values, or notify users - -### Example: Robust External Call - -```mdl -/** - * Calls external service with error handling - */ -create microflow Module.SafeExternalCall ( - $RequestData: string -) -returns Module.Response as $response -begin - -- The call output establishes $response — objects are never declared - $response = call microflow Module.CallExternalAPI(data = $RequestData) - on error without rollback { - log error node 'ExternalAPI' 'API call failed for: ' + $RequestData; - -- Create error response - $response = create Module.Response ( - success = false, - message = 'External service unavailable'); - }; - - return $response; -end; -/ -``` - -## UNSUPPORTED Syntax (Will Cause Parse Errors) - -**CRITICAL**: The following syntax is NOT implemented and will cause parse errors. Do NOT use these patterns: - -### ROLLBACK Statement (Supported!) - -```mdl --- CORRECT: ROLLBACK is now supported -rollback $Order; - --- With REFRESH to update client UI -rollback $Order refresh; -``` - -**Use Case**: Revert uncommitted changes to an object. Useful when validation fails and you want to restore the object to its database state. - -### RETRIEVE with LIMIT (Supported!) - -```mdl --- CORRECT: LIMIT is supported -retrieve $Product from Module.Product where IsActive = true limit 1; - --- LIMIT 1 returns a single entity (not a list) --- Without LIMIT, returns a list -retrieve $ProductList from Module.Product where IsActive = true; -``` - -### WHILE Loop - -```mdl --- WHILE loops iterate while a condition is true -while $Counter < 10 -begin - set $Counter = $Counter + 1; -end while; - --- FOR EACH loops iterate over a list -loop $item in $ItemList -begin - -- Process each item -end loop; -``` - -### CASE with string values, `else`, or an alias - -`case … end case` **is supported** — see [CASE Statements (Enum Split)](#case-statements-enum-split) -above for the correct form. What is not supported is the SQL-flavoured spelling of -it: quoted values, an `else` fallback, and an `AS` alias all fail. - -```mdl --- WRONG: case values are not string literals (parse error) -case $Order/Status - when 'Active' then set $Result = 1; -end case; - --- WRONG: case values are not qualified (parse error) -case $Order/Status - when MyModule.Status.Active then set $Result = 1; -end case; - --- WRONG: no AS alias (parse error: mismatched input 'as' expecting WHEN) -case $Order/Status as s - when Active then set $Result = 1; -end case; - --- WRONG: no else branch (MDL008 → mxbuild CE0079 + CE0773) -case $Order/Status - when Active then set $Result = 1; - else set $Result = 0; -end case; - --- CORRECT: bare enum values, one branch per value, including (empty) -case $Order/Status - when Active then set $Result = 1; - when Inactive then set $Result = 2; - when (empty) then set $Result = 0; -end case; -``` - -An enum split is the *only* thing `case` does — it branches on an enumeration, not -on arbitrary expressions. For anything else (a string comparison, a numeric range), -use nested `if … else … end if`. - -### TRY/CATCH Block - -```mdl --- WRONG: TRY/CATCH not supported -TRY - commit $Order; -CATCH - log error 'Commit failed'; -end TRY; - --- CORRECT: Use ON ERROR on specific activities -commit $Order on error { - log error 'Commit failed'; -}; -``` - -### BREAK/CONTINUE in Loops - -```mdl --- WRONG: BREAK/CONTINUE not supported -loop $item in $ItemList -begin - if $item/Skip = true then - continue; -- NOT SUPPORTED - end if; - if $item/Stop = true then - break; -- NOT SUPPORTED - end if; -end loop; - --- CORRECT: Use conditional logic -loop $item in $ItemList -begin - if $item/Skip = false and $item/Stop = false then - -- Process item - end if; -end loop; -``` - -### Reserved Words as Identifiers - -**Best practice: Always quote all identifiers** (attribute names, parameter names, entity names) with double quotes. This eliminates all reserved keyword conflicts and is always safe — quotes are stripped automatically by the parser. - -> **Exception — never quote `$`-prefixed variable/parameter references.** The quote -> rule is for *bare* names (entities, attributes, associations, declared parameter -> names). Variable/parameter **references** in expressions stay **unquoted**: -> `$Customer/Name`, `$currentObject`, `retrieve … from $List`. Quoting the `$` token -> (`"$Customer"`) breaks resolution. - -```mdl -create persistent entity Module."item" ( - "check": boolean default false, - "text": string(500), - "format": string(50), - "value": decimal, - "create": datetime, - "delete": datetime -); -``` - -Quoted identifiers also work for microflow parameter names: -```mdl -create microflow Module."Process" ("select": string, "type": integer) -begin - log info 'Processing'; - return; -end; -``` - ## Validation Checklist Before executing a microflow script, verify: @@ -1796,18 +489,6 @@ Before executing a microflow script, verify: - [ ] Parameters start with `$` prefix - [ ] Proper closing for control structures (`end if`, `end loop`) -## Common Studio Pro Errors - -| Error Code | Message | Fix | -|------------|---------|-----| -| CE0053 | Selected type is not allowed | Don't `declare` an object/list — get it from a parameter, retrieve, create, or loop (MDL043/MDL040) | -| CE0117 | Error in expression | Use qualified association names; use `not(expr)` not bare `not expr` | -| CE0104 | Action activity is unreachable | Remove code after RETURN | -| CE0105 | Must end with end event | Add RETURN statement | -| CE0008 | No action defined | Define action for activity | -| CW0094 | Variable never used | Remove unused variables or use them | -| MDL | Variable not declared | Use `declare $var type = value;` before SET | - ## Tips for Success 1. **Always use fully qualified names**: `Module.Entity`, `Module.Association` diff --git a/.claude/skills/mendix/write-microflows/reference/control-flow.md b/.claude/skills/mendix/write-microflows/reference/control-flow.md new file mode 100644 index 000000000..663413e0f --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/control-flow.md @@ -0,0 +1,378 @@ +# Control flow, error handling and annotations + +Supporting reference for [write-microflows](../SKILL.md). + +## Control Flow + +### IF Statements + +```mdl +-- Simple IF +if $value > 10 then + set $message = 'Greater than 10'; +end if; + +-- IF/ELSE +if $value > 100 then + set $Category = 'High'; +else + set $Category = 'Low'; +end if; + +-- Nested IF +if $Score >= 90 then + set $Grade = 'A'; +else + if $Score >= 80 then + set $Grade = 'B'; + else + set $Grade = 'C'; + end if; +end if; +``` + +**Important**: Always close with `end if` (not just `end`). + +### Enumeration Comparisons + +**CRITICAL**: When comparing enumeration values, use the fully qualified enumeration value, NOT a string literal. + +```mdl +-- CORRECT: Use fully qualified enumeration value +if $task/status = Module.TaskStatus.Completed then + set $IsComplete = true; +end if; + +if $Order/OrderStatus != Module.OrderStatus.Cancelled then + -- Process the order +end if; + +-- WRONG: Do NOT use string literals +-- IF $Task/Status = 'Completed' THEN -- INCORRECT! +``` + +Putting an enumeration **into** a string is the same mistake — concatenating it +directly is rejected, so render it first with `getCaption()` (the caption) or +`toString()` (the value name): + +```mdl +-- CORRECT +log warning 'Unexpected status: ' + getCaption($Order/Status); + +-- WRONG +log warning 'Unexpected status: ' + $Order/Status; +``` + +**Where the string form is and is not accepted** (verified against mxbuild 11.13.0 — +one microflow per row, `mx check` read per construct): + +| Context | `'Draft'` | Note | +|---------|-----------|------| +| Comparison in a decision — `if $O/Status = 'Draft'` | ❌ **CE0117** | The one that bites | +| Concatenation — `'x' + $O/Status` | ❌ **CE0117** | Use `getCaption()` / `toString()` | +| `change $O (Status = 'Draft')` | ✅ accepted | Slot is already enum-typed | +| `create M.E (Status = 'Draft')` | ✅ accepted | Same | +| Attribute `DEFAULT 'Draft'` | ✅ accepted | Documented as the legacy form | +| XPath constraint `[Status = 'Draft']` | ✅ accepted | Enums are strings at DB level | + +`mxcli check` does **not** flag the two failing rows (it does not type expressions — +see `docs/11-proposals/PROPOSAL_expression_type_checking.md`), so a script can pass +`check` and fail the build. The qualified form is valid in every row above: use it +everywhere and the distinction never has to be remembered. + +**Checking for empty enumeration:** +```mdl +if $entity/status = empty then + -- Enumeration is not set +end if; +``` + +### CASE Statements (Enum Split) + +Use `case` when a microflow branches on an enumeration value. + +```mdl +case $Status + when Open, Pending then + return true; + when Closed then + return false; + when (empty) then + return false; +end case; +``` + +`(empty)` represents an unset enumeration value. Multiple values can share one `when` branch by separating them with commas. Case values are bare identifiers — do **not** quote them. + +> **Every value needs a branch, including `(empty)` — and there is no `else`.** +> A Mendix enum split is an exclusive split with one outgoing flow per condition +> value, so an uncovered value fails the build with **CE0079** *"The 'X' condition +> value should be configured in properties for an outgoing flow."* `mxcli check` +> reports a missing `(empty)` branch as **MDL056**, and an `else` branch as +> **MDL008** (an `else` does not stand in for the missing flows: mxbuild reports +> CE0079 for each uncovered value *and* CE0773 on the else flow itself). +> +> The `(empty)` branch is required **even when the attribute is `not null`** — +> verified on Mendix 11.6.6. If several values share a path, put them in one +> branch (`when Open, Pending then`) rather than reaching for `else`. + +### Type Split And Cast Statements + +Use `split type` when a microflow branches on an object's runtime specialization. +Use `cast` inside a type branch to create the specialized variable used by the branch body. + +Branches are `when then`, the same as an enumeration split — one +statement, two subjects. + +```mdl +declare $IsSpecialized boolean = false; +split type $Input + when Sample.SpecializedInput then + cast $SpecificInput; + set $IsSpecialized = true; + when Sample.BaseInput then + when (empty) then +end split; +return $IsSpecialized; +``` + +Branch values are qualified entity names. + +> **`when (empty) then` is the null-object branch, not a default.** It is +> Mendix's `(empty)` outgoing flow, taken when the split variable is empty. It +> does **not** cover types you did not name — see the CE0090 note below. +> +> **The older spelling still works.** `case Sample.SpecializedInput` (no `then`) +> and `else` for the empty branch parse and build the identical flow, but warn +> **MDL065**. `case` introduced a *branch* here while introducing the *subject* +> in `case $x when V then` and in expressions, so the word meant two things +> (mxcli #913); `else` read as a default and never was one. + +> **Every type needs a branch — including the base entity.** An object-type +> decision gets one outgoing flow per listed type, and a type with no flow fails +> the build with **CE0090** *"The 'X' value should be configured for an outgoing +> flow."* The base entity (the split variable's own type) counts: `case +> Sample.BaseInput` above is what covers "it is not any of the specializations". +> +> **The `(empty)` branch does not stand in for the base-type case.** It is +> accepted — it serializes as `Microflows$NoCase` — but it does not satisfy +> coverage, so one named type plus `when (empty) then` still fails CE0090. +> Measured on 11.13.0: `when Zoo.Dog then` + an empty branch gives CE0090 for +> `Zoo.Cat` **and** `Zoo.Animal`; branch on every type, keep the empty branch, +> and it is 0 errors. Verified on Mendix 11.6.6 and 11.13.0. +> +> You cannot drop the empty branch either — that is **CE0089**. mxcli emits the +> flow unconditionally for that reason. +> +> **The split needs somewhere to go afterwards.** Branch bodies converge on a +> merge that continues to the microflow's end event, so a non-void microflow +> needs a `return` after `end split;` — otherwise `mxcli check` reports MDL003 +> and the build fails **CE0067** *"The 'Return value' property is required."* +> Doing the per-branch work into a variable and returning it once (above) is the +> clearest shape; returning inside every branch also works, but still needs the +> trailing `return`. + +**`cast` only stores the output variable.** Studio Pro persists Microflows$CastAction with a single `VariableName` field — the source variable is implicit (the type-split's input). Use `cast $SpecificName;` to give the specialized variable its name. The two-variable form `$Output = cast $Source;` parses but `$Source` is dropped on roundtrip; prefer the single-variable form. + +### LOOP Statements + +```mdl +-- Basic loop +loop $Product in $ProductList +begin + set $count = $count + 1; +end loop; + +-- Loop with object modification +loop $Product in $ProductList +begin + change $Product (IsActive = true); + commit $Product; +end loop; + +-- Loop with conditional logic +loop $Product in $ProductList +begin + if $Product/IsActive then + set $ActiveCount = $ActiveCount + 1; + end if; +end loop; + +-- Label a loop with a note (loops have no caption in Mendix) +@annotation 'Process each product' +loop $Product in $ProductList +begin + set $count = $count + 1; +end loop; +``` + +> **`@caption` does nothing on a loop.** Mendix for-loops have no caption +> property, so `@caption` on a `loop` is silently dropped (`mxcli check` flags +> it as **MDL042**). To label a loop, use `@annotation 'text'` — it attaches a +> note, exactly like drawing one onto the loop in Studio Pro. + +**Note**: +- Loop variable (`$Product`) is scoped to the loop body +- The loop variable type is **automatically derived** from the list type (e.g., `list of Test.Product` → `Test.Product`) +- CHANGE statements inside loops use the derived type to resolve attribute names + +> **Nothing a loop defines survives past `end loop;`.** The iterator *and* +> anything the body creates (a `retrieve`, a `$X = create …`, a call output) are +> visible only inside the body; using one afterwards is +> `CE0108 "Variable 'X' is defined but not in scope at this location."` +> (`mxcli check` flags it as **MDL053**). +> +> ```mdl +> -- WRONG: $Last is created inside the loop, read outside it +> loop $Item in $Items +> begin +> $Last = create Test.Product (Name = $Item/Name); +> end loop; +> commit $Last; -- MDL053 / CE0108 +> +> -- RIGHT: declare before the loop, assign inside, read after +> declare $LastName string = ''; +> loop $Item in $Items +> begin +> set $LastName = $Item/Name; +> end loop; +> log info node 'Test' $LastName; +> ``` +> +> Visibility and *naming* are separate rules: names must also be unique across +> the **whole** microflow, so two loops cannot share an iterator name either +> (`CE0111`, flagged as **MDL052**). + +### Performance: Batch Commit After Loop + +**CRITICAL**: Do NOT commit inside a loop. Each `commit` inside a loop issues a separate database transaction, which causes N round-trips for N records and degrades performance significantly. + +❌ **INCORRECT — commit inside loop (N transactions):** +```mdl +loop $Binding in $BindingsList +begin + $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); + commit $NewBatch with events; -- ❌ one DB transaction per record +end loop; +``` + +✅ **CORRECT — create list before loop, commit once after:** +```mdl +$BatchList = create list of BatteryOntology.MaterialBatch; +loop $Binding in $BindingsList +begin + $NewBatch = create BatteryOntology.MaterialBatch (BatchNo = $BatchNoObj/Value); + add $NewBatch to $BatchList; -- accumulate in memory +end loop; +commit $BatchList with events on error rollback; -- ✅ single transaction +``` + +**Pattern:** +1. Before the loop: `$XxxList = create list of Module.Entity;` +2. Inside the loop: `add $NewXxx to $XxxList;` (replaces `commit`) +3. After the loop: `commit $XxxList with events on error rollback;` + +This applies whenever the loop **creates** new objects. For loops that only **change** existing objects, the same pattern applies — accumulate changed objects in a list, commit the list once outside the loop. +## Activity Annotations + +Annotations use `@` prefix syntax placed before the activity they apply to: + +```mdl +-- Canvas position (always shown in DESCRIBE output) +@position(200, 200) +commit $Order with events; + +-- Custom caption (overrides auto-generated caption) +@caption 'Save the order' +commit $Order with events; + +-- Background color (Blue, Green, Red, Yellow, Purple, Gray) +@color Green +log info node 'App' 'Success'; + +-- Visual note attached to the next activity (creates AnnotationFlow) +@annotation 'Validate the order before processing' +commit $Order with events; + +-- Multiple annotations stacked on a single activity +@position(400, 200) +@caption 'Persist product' +@color Blue +@annotation 'Step 2: Save to database' +commit $Product; +``` + +**Rules:** +- `@annotation` before an activity attaches the note to that activity +- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity +- `@annotation` at the end (no following activity) creates a free-floating note +- Escape single quotes by doubling: `@annotation 'Don''t forget'` +- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default +- DESCRIBE MICROFLOW shows `@` annotations before their activities +- `@start(x, y)` positions the **start event** and goes on the first statement, because the start has no statement of its own. Omit it and the start is derived — one spacing unit (160) left of the first activity, on its centre line — and a rewrite re-derives it so the start follows the activities when they move. A start that is not at the derived spot was placed by hand (in Studio Pro or with `@start`): it survives a rewrite that does not mention it, and DESCRIBE emits `@start` for it. An explicit `@start` overrides both (#951) +## Error Handling + +MDL supports error handling for activities that may fail (microflow calls, commits, external service calls, etc.). + +### Error Handling Types + +```mdl +-- ON ERROR CONTINUE: Ignore error and continue execution +call microflow Module.RiskyOperation() on error continue; + +-- ON ERROR ROLLBACK: Rollback transaction and propagate error +commit $Order with events on error rollback; + +-- ON ERROR { ... }: Custom error handler with rollback +$Result = call microflow Module.ExternalService(data = $data) on error { + log error node 'ServiceError' 'External service failed'; + return $DefaultResult; +}; + +-- ON ERROR WITHOUT ROLLBACK { ... }: Custom handler, keep changes +commit $Order on error without rollback { + log warning node 'CommitError' 'Commit failed, using fallback'; + change $Order (status = 'PENDING'); +}; +``` + +### Error Handling Semantics + +| Syntax | Behavior | +|--------|----------| +| `on error continue` | Catch error silently, continue normal flow | +| `on error rollback` | Rollback database changes, propagate error | +| `on error { ... }` | Execute handler block, then continue (with rollback) | +| `on error without rollback { ... }` | Execute handler block, keep database changes | + +### When to Use Each Type + +- **CONTINUE**: Non-critical operations where failure is acceptable +- **ROLLBACK**: Critical operations where data integrity must be preserved +- **Custom handlers**: When you need to log errors, set fallback values, or notify users + +### Example: Robust External Call + +```mdl +/** + * Calls external service with error handling + */ +create microflow Module.SafeExternalCall ( + $RequestData: string +) +returns Module.Response as $response +begin + -- The call output establishes $response — objects are never declared + $response = call microflow Module.CallExternalAPI(data = $RequestData) + on error without rollback { + log error node 'ExternalAPI' 'API call failed for: ' + $RequestData; + -- Create error response + $response = create Module.Response ( + success = false, + message = 'External service unavailable'); + }; + + return $response; +end; +/ +``` diff --git a/.claude/skills/mendix/write-microflows/reference/data-operations.md b/.claude/skills/mendix/write-microflows/reference/data-operations.md new file mode 100644 index 000000000..bb3e1a313 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/data-operations.md @@ -0,0 +1,238 @@ +# Objects, lists, database and XPath + +Supporting reference for [write-microflows](../SKILL.md). + +## Object Operations + +### CREATE Object + +```mdl +$NewProduct = create Test.Product ( + Name = $Name, + Code = $Code, + IsActive = true, + CreateDate = [%CurrentDateTime%]); +``` + +**Syntax Rules:** +- Variable assignment on left side (`$NewProduct =`) +- Entity type is fully qualified +- Attributes in parentheses, comma separated +- Closing `)` followed by semicolon +- Syntax aligned with CALL MICROFLOW/CALL JAVA ACTION + +**The Commit flag** (Studio Pro's "Commit" dropdown) is the optional `commit` +modifier after the member list: + +```mdl +$Order = create Sales.Order (Number = $Nr); -- Commit: No (default) +$Order = create Sales.Order (Number = $Nr) commit; -- Commit: Yes +$Order = create Sales.Order (Number = $Nr) commit without events;-- Commit: YesWithoutEvents +``` + +Omit it for the default. This is a **modifier on the create**, not the separate +`commit $Var;` activity — see COMMIT Object below for that one. + +### CHANGE Object + +```mdl +change $Product ( + Name = $NewName, + ModifiedDate = [%CurrentDateTime%]); + +-- Commit the changed object as part of the change activity +change $Product (Name = $NewName) commit; +change $Product (Name = $NewName) commit without events; + +-- Refresh the changed object in the client +change $Product (Name = $NewName) refresh; + +-- Both: commit comes first +change $Product (Name = $NewName) commit refresh; +``` + +**Note**: Only specify attributes you want to change. Syntax aligned with CREATE. + +### COMMIT Object + +```mdl +-- Commit without events +commit $Product; + +-- Commit with events (triggers event handlers) +commit $Product with events; + +-- Commit with refresh in client (updates UI after commit) +commit $Product refresh; + +-- Commit with events and refresh +commit $Product with events refresh; +``` + +**Best Practice**: Use `with events` when you want before/after commit event handlers to execute. Use `refresh` when the committed object is displayed in the client and you want the UI to update immediately. + +> **Re-sorting a database-datasource grid needs `refresh`.** A plain `commit $Obj;` updates the committed attribute *values* in the grid, but a grid backed by a **database** datasource does **not** re-run its sort — so after changing a sort key (e.g. a reorder that rewrites a `SequenceNumber`), the row stays in its old position until you `commit $Obj refresh;`. The `refresh` re-queries the datasource, which re-applies the sort. (Ledger #57.) + +> **Binding a microflow to an entity event is MDL — you do NOT need to map it manually in Studio Pro.** After writing a handler microflow (e.g. a `BeforeCommit` validation), wire it directly: +> ```mdl +> alter entity Sales.Order +> add event handler on before commit call Sales.ACT_ValidateOrder($currentObject) raise error; +> ``` +> Works for `before`/`after` × `create`/`commit`/`delete`/`rollback`, and inside `create entity` too. See [generate-domain-model](../../generate-domain-model/SKILL.md) for the full syntax. (There is no need for an `EVT_*` naming convention plus a manual Studio Pro mapping step.) +## List Operations + +```mdl +-- Existing variable form +add $Item to $Items; + +-- Expression-valued add, useful when round-tripping Studio Pro list-add values +add head($SourceItems) to $Items; +``` + +Use expression-valued `add` only when the expression returns an object compatible with the target list element type. + +### `contains` is overloaded — string vs list + +`contains(a, b)` is both a **string** function (`contains(haystack, needle)` → substring test) and a **list** operation (`contains(list, object)` → membership test). mxcli picks the right serialization automatically: + +```mdl +-- STRING contains — assign to a PRE-DECLARED Boolean (a Change Variable action) +declare $HasAt Boolean = false; +set $HasAt = contains($Email, '@'); + +-- LIST contains — do NOT pre-declare the output (the list op creates it) +set $Found = contains($Items, $Item); +``` + +The distinction: a **literal or computed** second argument is always the string function. When both arguments are plain variables, the input variable's declared type decides — a **String** input becomes the string function (Change Variable, so declare the Boolean first), anything else stays a list operation (which creates its own output variable, so leave it undeclared). Getting the declare wrong is what triggers `CE0111 "Duplicate variable name"`. +## Database Operations + +### RETRIEVE Statement + +```mdl +-- Retrieve all +retrieve $ProductList from Test.Product; + +-- Retrieve with WHERE +retrieve $ProductList from Test.Product + where Code = $SearchCode; + +-- Retrieve with multiple conditions +retrieve $ProductList from Test.Product + where IsActive = true + and Price > 100; + +-- Retrieve single object +retrieve $Product from Test.Product + where Code = $ProductCode; +``` + +**Important**: +- Use `from Module.Entity` (fully qualified) +- RETRIEVE with `limit 1` returns a **single entity** +- RETRIEVE without `limit 1` returns a **list** (`list of Module.Entity`) +- Use `limit 1` when you expect exactly one result (e.g., lookup by unique key) + +**Sorting and paging** — use `sort by`, **not** `order by`: + +```mdl +retrieve $Recent from Sales.Order + where Status = Sales.OrderStatus.Open + sort by Sales.Order.OrderDate desc, Sales.Order.OrderNumber asc + limit $PageSize + offset $Offset; +``` + +- The keyword is **`sort by`** (one or more `Module.Entity.Attr asc|desc`, comma-separated). `order by` is **not** valid on a microflow `retrieve` — it's reserved for `select ... from CATALOG.*` queries and will cause a parse error here. +- `limit` and `offset` accept **a variable or expression**, not only a literal — `limit $PageSize`, `offset $Offset`, even `limit $Base + 5` all work. A bare literal (`limit 20`) is just the simplest case. + +### Retrieve by Association (in-memory, over an association path) + +To get the object(s) related to one you already have, retrieve **over an +association** — `retrieve $out from $source/Module.Association;`. This is an +*association* (in-memory) retrieve, not a database query, so it has no `where` / +`sort by` / `limit`. **The result type depends on the direction you navigate:** + +```mdl +-- FORWARD (from the reference-owner / "one" side) → a SINGLE object. +-- An Expense has one Employee (Expense_Employee: from Expense to Employee): +retrieve $Employee from $Expense/MyFirstModule.Expense_Employee; +change $Employee (Name = 'Updated'); -- change it directly — do NOT loop + +-- REVERSE (from the "many" side) → a LIST of the related objects. +-- One Employee has many Expenses (same association, navigated the other way): +retrieve $Expenses from $Employee/MyFirstModule.Expense_Employee; +loop $Expense in $Expenses +begin + change $Expense (Amount = 0); +end loop; +``` + +- **Forward Reference traversal returns a single object** — do **not** `loop` over + it. Looping a single object passes `mxcli check` but produces an invalid project + (mxbuild `StorageLoadException` — the loop's `change` writes an unqualified + attribute). Loop only over the list-valued (reverse / ReferenceSet) form. +- The association is always fully qualified (`Module.Association`), and the start + variable is an object you already have (a parameter, a prior retrieve/create, or + a loop iterator). Both forms above are mxbuild-verified (0 errors). + +**Enumeration attributes in WHERE**: XPath is a database query, so enum values are stored as plain strings. Both forms are valid — mxcli converts the qualified name to a string literal in BSON: + +```mdl +-- Preferred: qualified name (mxcli converts to 'Open' in BSON) +retrieve $Open from Module.Order + where [Status = Module.OrderStatus.Open]; + +-- Also accepted: string literal (the value key, case-sensitive) +retrieve $Open from Module.Order + where [Status = 'Open']; + +-- Multiple enum values with OR +retrieve $InProgress from Module.Order + where [Status = Module.OrderStatus.Open or Status = Module.OrderStatus.Processing]; +``` + +This is different from IF/SET expressions — see "Enumeration Comparisons" section above. +## XPath Navigation + +### Attribute Access + +```mdl +-- Read attribute +declare $ProductName string = $Product/Name; +declare $Price decimal = $Product/Price; + +-- Write attribute (alternative to CHANGE) +set $Product/Price = $NewPrice; +set $Product/ModifiedDate = [%CurrentDateTime%]; +``` + +### Association Navigation + +```mdl +-- Navigate to related object +declare $CustomerName string = $Order/Shop.Order_Customer/Name; +declare $CategoryName string = $Product/Shop.Product_Category/Name; + +-- Set association +set $Order/Shop.Order_Customer = $Customer; +set $Order/Shop.Order_Product = $Product; +``` + +**Critical**: Always use fully qualified association names (`Module.AssociationName`). + +### XPath in Expressions + +```mdl +-- Use in calculations +declare $MonthlyTotal decimal = $Product/MonthlyTotal; +declare $DailyAverage decimal = $MonthlyTotal div 30; + +-- Use in conditions +if $Product/IsActive then + set $count = $count + 1; +end if; + +-- Combine with operators +set $TotalPrice = $Product/Price * $Quantity; +``` diff --git a/.claude/skills/mendix/write-microflows/reference/integration.md b/.claude/skills/mendix/write-microflows/reference/integration.md new file mode 100644 index 000000000..de1c73da7 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/integration.md @@ -0,0 +1,212 @@ +# REST, SOAP, Java actions and files + +Supporting reference for [write-microflows](../SKILL.md). + +## Legacy SOAP Web Service Calls + +`call web service` preserves legacy Mendix SOAP activities. Prefer REST clients +for new integrations; this syntax exists mainly so existing projects can +round-trip without dropping SOAP actions. + +```mdl +-- Structured form. Resolved SOAP references use normal qualified names. +$Root = call web service SampleSOAP.OrderService +operation FetchSampleItems +send mapping SampleSOAP.OrderRequest +receive mapping SampleSOAP.OrderResponse +timeout 30 +on error rollback; + +-- Quoted raw IDs are accepted when old project references are dangling or unavailable. +$Root = call web service 'sample-service-id' +operation FetchSampleItems +send mapping 'sample-send-mapping-id' +receive mapping 'sample-receive-mapping-id'; + +-- Raw escape hatch emitted for unsupported SOAP fields. +$Root = call web service raw 'AQID'; +``` + +**Design note:** the raw payload is base64-encoded BSON for the complete action +and is authoritative on re-exec. Treat this as round-trip support, not a +recommended authoring format for new integrations. +## REST Service Calls + +MDL supports two patterns for calling REST APIs from microflows: + +### SEND REST REQUEST — Consumed REST Service Operations + +Calls an operation defined in a consumed REST service (created via `create rest client`). The URL, headers, authentication, and response mapping are configured in the REST client document — the microflow only references the operation. + +```mdl +-- Fire and forget (RESPONSE NONE operation) +send rest request Module.ServiceName.OperationName; + +-- With output variable (RESPONSE JSON operation — maps to entity) +$Result = send rest request Module.ServiceName.OperationName; + +-- With request body (POST/PUT operations) +$Result = send rest request Module.ServiceName.CreateItem + body $NewItem; +``` + +**CRITICAL: `$latestHttpResponse` system variable** + +After every `send rest request`, Mendix automatically populates `$latestHttpResponse` (type `System.HttpResponse`). Use this to check call success — do **NOT** check the output variable directly: + +```mdl +-- ✅ CORRECT: check $latestHttpResponse +$RootResult = send rest request Module.Service.GetData; +if $latestHttpResponse/Content != empty then + -- Process $RootResult (the mapped entity) +end if; + +-- ❌ WRONG: checking the output variable directly causes CE0117 +if $RootResult != empty then -- ERROR! +``` + +**Key attributes on `$latestHttpResponse`:** +- `Content` (String) — response body as string. **Capital `C`** — it is inherited from the parent entity `System.HttpMessage`, so `DESCRIBE ENTITY System.HttpResponse` does not list it. Lowercase `$latestHttpResponse/content` fails CE0117. +- `StatusCode` (Integer) — HTTP status code (200, 404, etc.) + +**Restrictions:** +- `send rest request` does **NOT** support custom error handling (`on error continue/rollback` causes CE6035). Errors are always handled by aborting. +- The operation must be defined via `create rest client` with a three-part qualified name: `Module.ServiceDocument.OperationName`. + +### REST CALL — Inline HTTP Calls + +Direct HTTP call with URL, headers, auth, body, and response handling specified inline. Useful for one-off calls or when no REST client document exists. + +```mdl +-- Simple GET returning string +$response = rest call get 'https://api.example.com/data' + header Accept = 'application/json' + timeout 30 + returns string; + +-- POST with JSON body +$response = rest call post 'https://api.example.com/items' + header 'Content-Type' = 'application/json' + header Accept = 'application/json' + body '{{"name": "{1}", "value": {2}}' with ( + {1} = $ItemName, + {2} = toString($ItemValue) + ) + timeout 30 + returns string + on error continue; + +-- POST a BINARY body (upload a file document's contents) +-- The expression is the FileDocument's Contents MEMBER, not the document +-- itself, and the content type goes on a header. A consumed REST CLIENT +-- document has no binary body — `Body: file from $Doc` there is refused as +-- MDL-REST02 — so binary uploads belong here. +$response = rest call post 'https://api.example.com/upload' + header 'ContentType' = 'application/pdf' + body binary $Doc/Contents + timeout 300 + returns response; + +-- GET with URL template parameters +$response = rest call get 'https://api.example.com/users/{1}' with ( + {1} = toString($UserId) +) + header Accept = 'application/json' + returns string; + +-- With basic authentication +$response = rest call get 'https://api.example.com/secure' + header Accept = 'application/json' + auth basic $username password $password + timeout 30 + returns string; + +-- DELETE (no response) +rest call delete 'https://api.example.com/items/{1}' with ( + {1} = $ItemId +) + returns nothing + on error continue; +``` + +**REST CALL response types:** +- `returns string` — response body as string variable +- `returns nothing` / `returns none` — ignore response +- `returns response` — returns `System.HttpResponse` object +- `returns mapping Module.ImportMapping as Module.Entity` — single object result +- `returns mapping Module.ImportMapping as list of Module.Entity` — list result +- `returns Module.MyFile` — store the body in a **file document** + +**The file document form takes a specialization, never `System.FileDocument` +itself.** Mendix rejects the base type as a return type with `CE0362`, and +MDL064 reports it before the write. Create one first: + +```mdl +create persistent entity MyModule.MyFile extends System.FileDocument (); + +create microflow MyModule.ACT_Download ($Location: String) +begin + $file = rest call get '{1}' with ({1} = $Location) + header 'Accept' = 'application/octet-stream' + timeout 300 + returns MyModule.MyFile; +end; +``` + +There is **no** equivalent for an HttpResponse specialization: Mendix allows only +`User`, `FileDocument`, `Image` and `Paging` to be specialized (`CE1540`), so +`returns response` already names the only type that result can have. + +**Pick `as` vs `as list of` based on the call site, not the mapping shape.** The same import mapping can yield either a single object or a list — Studio Pro stores the cardinality on the microflow's `ImportMappingCall` (`Range.SingleObject` + `ForceSingleOccurrence`). Use `as Module.Entity` when the response is a single object (the mapping may still be list-typed; Studio Pro binds the first item). Use `as list of Module.Entity` when the response should bind a list. Mismatching the cardinality with the surrounding code produces `mx check` `CE0117` at the End event or `CE0013` / `CE0100` on downstream loop / aggregate / list-operation activities. + +**REST CALL supports full error handling** (`on error continue`, `on error rollback`, custom error handlers). +## File Downloads + +Use `download file` to stream a `System.FileDocument` from a microflow. Add +`show in browser` when the action should open the file inline instead of forcing +a download. + +```mdl +download file $GeneratedReport show in browser; +download file $GeneratedExport; +``` +## Empty Java-Action Argument (`empty`) + +When `describe` round-trips a Java-action call that has an unbound parameter +in Studio Pro, it emits `empty` as the argument value. In this Java-action +argument context, `empty` preserves the +underlying empty `BasicCodeActionParameterValue.Argument` so that the next +`describe → exec → describe` cycle stays symmetric. + +```mdl +$Total = call java action SampleModule.Recalculate( + CompanyId = empty, + RecalculateAll = true, + ItemList = empty +); +``` + +New scripts should bind every parameter to a real expression. Use `empty` +for a Java-action argument only when regenerating MDL from an existing project +that already had an unbound parameter. +## Microflow-Typed Java-Action Parameters + +Some Java actions take a **microflow** — a callback the action invokes later. +`MCPServer.AddTool` (`ExecutingMicroflow`) and `MCPServer.CreateMCPServer` +(`AuthenticationMicroflow`) are the ones you meet first. Pass the microflow's +qualified name as a quoted string; mxcli resolves the parameter's declared type +from the Java action and stores a microflow reference, not a string literal. + +```mdl +$Tool = call java action MCPServer.AddTool( + McpServer = $Server, + Name = 'memory_add', + Description = 'Stores a memory', + ExecutingMicroflow = 'MyModule.MF_MemoryAdd', + Schema = '' +); +``` + +`DESCRIBE JAVA ACTION` prints such a parameter's type as the bare word +`Microflow` (`Nanoflow` for JavaScript actions), and that spelling is what +`CREATE JAVA ACTION` accepts, so the round-trip is stable. diff --git a/.claude/skills/mendix/write-microflows/reference/pitfalls.md b/.claude/skills/mendix/write-microflows/reference/pitfalls.md new file mode 100644 index 000000000..e7027e7e2 --- /dev/null +++ b/.claude/skills/mendix/write-microflows/reference/pitfalls.md @@ -0,0 +1,509 @@ +# Pitfalls, unsupported syntax and build errors + +Supporting reference for [write-microflows](../SKILL.md). + +## Common Pitfalls + +### 1. Object (Entity) Variables Cannot Be Declared + +**Error**: CE0053 - "Selected type is not allowed" (+ CE0038, CE7247) — passes +`mxcli check` only until MDL043 was added; previously surfaced at mxbuild. + +❌ **INCORRECT:** +```mdl +declare $Product Test.Product; -- object Create Variable — rejected +declare $Product Test.Product = $In; -- aliasing a parameter — rejected +declare $Product as Test.Product; -- AS keyword also not supported +``` + +✅ **CORRECT** — get the object from a source that produces one: +```mdl +-- as a microflow parameter +create microflow Test.Save ($Product: Test.Product) returns boolean as $ok ... + +-- from a retrieve (single object) +retrieve $Product from Test.Product where Code = $Code limit 1; + +-- from a create object +$Product = create Test.Product (Name = $Name); + +-- from a loop iterator +loop $Product in $Products +begin + change $Product (Processed = true); +end +``` + +**Explanation**: `declare` becomes a *Create Variable* activity, which in Mendix +only holds primitive types. Objects (and lists) have no Create Variable form — use +the parameter/retrieve/create/loop sources above. To "keep a reference" to an +existing object, just use the variable you already have (`$In`, the loop variable, +the retrieve/create output); Mendix has no aliasing activity. + +### 2. XPath Association Navigation + +**Error**: CE0117 - "Error in expression" + +❌ **INCORRECT:** +```mdl +-- Using simple association name +declare $CustomerName string = $Order/Customer/Name; +set $Name = $Product/Category/Name; +``` + +✅ **CORRECT:** +```mdl +-- Use fully qualified association name: Module.AssociationName +declare $CustomerName string = $Order/Shop.Order_Customer/Name; +set $Name = $Product/Shop.Product_Category/Name; +``` + +**Explanation**: XPath navigation requires the full qualified association name in the format `Module.AssociationName`. + +### 3. Missing Attributes + +**Error**: Attribute references must exist in entity definition + +❌ **INCORRECT:** +```mdl +-- Referencing Status when it doesn't exist in Order entity +change $Order ( + status = 'PROCESSING', + ProcessedDate = [%CurrentDateTime%]); +``` + +✅ **CORRECT:** +```mdl +-- First, ensure entity has the attributes +create persistent entity Shop.Order ( + OrderNumber: string(50), + status: string(50), -- ← Must be defined + ProcessedDate: datetime -- ← Must be defined +); + +-- Then reference them +change $Order ( + status = 'PROCESSING', + ProcessedDate = [%CurrentDateTime%]); +``` + +### 4. Flow Must End with RETURN + +**Error**: CE0105 - "Activity cannot be the last object of a flow" + +❌ **INCORRECT:** +```mdl +begin + declare $success boolean = true; + log info 'Done'; + -- Missing RETURN! +end; +``` + +✅ **CORRECT:** +```mdl +begin + declare $success boolean = true; + log info 'Done'; + return $success; -- ← Always required +end; +``` + +### 5. Unreachable Code After RETURN + +**Error**: CE0104 - "Action activity is unreachable" + +❌ **INCORRECT:** +```mdl +if $value < 0 then + return false; + log info 'This will never execute'; -- ← Unreachable! +end if; +``` + +✅ **CORRECT:** +```mdl +if $value < 0 then + log info 'Value is negative'; + return false; +end if; +``` + +### 6. Unused Variables + +**Warning**: CW0094 - "Variable 'X' is never used" + +```mdl +-- Studio Pro will warn if parameters/variables are declared but never used +create microflow Test.Example ( + $ProductCode: string -- ← Warning if never referenced +) +returns boolean as $success +begin + set $success = true; -- ProductCode never used + return $success; +end; +``` + +### 7. Using SET on Undeclared Variables + +**Error**: MDL executor validates that all variables used with `set` are declared first. + +❌ **INCORRECT:** +```mdl +begin + if $value > 10 then + set $message = 'High'; -- ERROR: $Message not declared! + end if; + return true; +end; +``` + +✅ **CORRECT:** +```mdl +begin + declare $message string = ''; -- Declare first + if $value > 10 then + set $message = 'High'; -- Now SET works + end if; + return true; +end; +``` + +**Note**: Parameters are automatically declared by the parameter list. The `returns type as $Var` syntax names the return variable but does NOT declare it - you must still use `declare $Var type = value;` if you want to use SET on it. + +### 8. RETURN Inside a Loop + +**Error**: CE0068 - "End events cannot be placed inside a loop." (MDL062) + +A `return` builds an End event, and Mendix does not allow one inside a loop — +whether the return sits in the loop body directly or inside a branch within it. + +❌ **INCORRECT:** +```mdl +loop $Part in $PartList +begin + if $Part/IsMatch then + return true; -- End event inside the loop + end if; +end loop; +``` + +✅ **CORRECT** — leave the loop with `break`, and return once after it: +```mdl +declare $Found boolean = false; +loop $Part in $PartList +begin + if $Part/IsMatch then + set $Found = true; + break; + end if; +end loop; +return $Found; +``` + +### 9. Two Activities Creating the Same Variable + +**Error**: CE0111 - "Duplicate variable name 'X'." (MDL063) + +A microflow's variable names are unique **flow-wide**. Branches and loop bodies +do not open a scope, and parameters and loop iterators share the same namespace. +The trap is that every activity with an output **creates** its variable — there +is no form in which a call, a retrieve, an aggregate or an import mapping writes +into one that already exists. + +❌ **INCORRECT:** +```mdl +declare $Session string = ''; +$Session = call microflow Mod.Login(); -- the call creates $Session too +``` + +✅ **CORRECT** — let the activity create it: +```mdl +$Session = call microflow Mod.Login(); +``` + +Assigning to an existing variable is fine, because `set` is a *Change Variable* +activity and creates nothing: + +```mdl +declare $Session string = ''; +set $Session = 'anonymous'; -- valid, any number of times +``` + +### 10. Calling a Rule or Microflow Inside an Expression + +**Error**: CE0117 - "Error(s) in expression." (MDL066) + +A Mendix **expression** has no user-callable functions. Its library is built-in +and unqualified (`length`, `toString`, `contains`, ...); microflows, rules and +Java actions are called by **activities**. So a qualified call in a value +position is not an expression at all — mxbuild rejects it whichever document it +names. + +❌ **INCORRECT:** +```mdl +declare $Active Boolean = Sample.Rule_IsActive(IsActive = $IsActive); +declare $Next Integer = Sample.MF_Increment(N = $N); -- a microflow is no better +``` + +✅ **CORRECT** — a microflow or Java action is an activity: +```mdl +$Next = call microflow Sample.MF_Increment(N = $N); +``` + +A **rule** has no call activity at all: Mendix can only evaluate one as a +decision's condition, and that is the single position where a bare qualified +call is valid MDL. + +```mdl +if Sample.Rule_IsActive(IsActive = $IsActive) then + ... +end if; +``` + +The name in that position must resolve to a real **rule** — a microflow there is +the same CE0117, and mxcli refuses the statement rather than writing it. +## Implicit Variable Creation (CE0111 Duplicate Variable) + +These statements **implicitly create a new variable** with the name on the left side: + +- `$Var = call microflow ...` +- `$Var = call java action ...` +- `$Var = call nanoflow ...` +- `$Var = create Module.Entity (...)` +- `retrieve $Var from Module.Entity ...` + +**Do NOT use `declare` before these** — it creates a duplicate variable (CE0111): + +```mdl +-- WRONG: Duplicate variable — DECLARE + CALL both create $Result +declare $Result boolean = false; +$Result = call java action Module.DoSomething(); -- CE0111! + +-- CORRECT: Let CALL create the variable, use a different name if you need a default +declare $success boolean = false; +$CallResult = call java action Module.DoSomething(); +set $success = $CallResult; + +-- CORRECT: Simple pass-through (no default needed) +$Result = call java action Module.DoSomething(); +return $Result; +``` + +The same applies to RETRIEVE: + +```mdl +-- WRONG: declaring a list at all (CE0053/CE0038, MDL040) — and duplicate variable +declare $Items list of Module.Entity = empty; +retrieve $Items from Module.Entity where Active = true; -- CE0111! + +-- CORRECT: Let RETRIEVE create the variable +retrieve $Items from Module.Entity where Active = true; +``` + +**Note**: `returns type as $Var` in the microflow signature does NOT create an activity variable — it only names the return value. So `$Var = call java action ...` after `returns as $Var` is fine (one creation). + +**Variables are scoped to the branch that creates them.** A variable first created +inside an `if`/`else` arm (including by `$Var = call ...`) is not visible outside +that arm. To use one value after a conditional, `declare` it *before* the +conditional and assign in every branch: + +```mdl +-- WRONG: $GTotalText is created only in the `then` arm → not declared in `else` +if $HasVariance then + $GTotalText = call microflow Module.FMT_Variance($v); -- created here only +else + set $GTotalText = 'n/a'; -- error: not declared +end if; + +-- CORRECT: declare before, then set in each branch (call into a temp, then set) +declare $GTotalText string = ''; +if $HasVariance then + $Tmp = call microflow Module.FMT_Variance($v); + set $GTotalText = $Tmp; +else + set $GTotalText = 'n/a'; +end if; +``` + +**Fallback chains reuse the same name = CE0111.** Because each `$Var = call microflow …` (or retrieve/create) is a *fresh* variable creation, the natural "try A, else try B" shape is invalid — even when the retry is inside an `if`: + +```mdl +-- WRONG: the second call re-creates $Summary — CE0111 +$Summary = call microflow M.Inner(Tag = 'description'); +if trim($Summary) = '' then + $Summary = call microflow M.Inner(Tag = 'summary'); -- CE0111! +end if; + +-- CORRECT: one variable per call, then a plain `set` picks the winner +$Summary = call microflow M.Inner(Tag = 'description'); +if trim($Summary) = '' then + $SummaryFallback = call microflow M.Inner(Tag = 'summary'); + set $Summary = $SummaryFallback; +end if; +``` + +`mxcli check --references` catches this (CE0111) before the build. +## UNSUPPORTED Syntax (Will Cause Parse Errors) + +**CRITICAL**: The following syntax is NOT implemented and will cause parse errors. Do NOT use these patterns: + +### ROLLBACK Statement (Supported!) + +```mdl +-- CORRECT: ROLLBACK is now supported +rollback $Order; + +-- With REFRESH to update client UI +rollback $Order refresh; +``` + +**Use Case**: Revert uncommitted changes to an object. Useful when validation fails and you want to restore the object to its database state. + +### RETRIEVE with LIMIT (Supported!) + +```mdl +-- CORRECT: LIMIT is supported +retrieve $Product from Module.Product where IsActive = true limit 1; + +-- LIMIT 1 returns a single entity (not a list) +-- Without LIMIT, returns a list +retrieve $ProductList from Module.Product where IsActive = true; +``` + +### WHILE Loop + +```mdl +-- WHILE loops iterate while a condition is true +while $Counter < 10 +begin + set $Counter = $Counter + 1; +end while; + +-- FOR EACH loops iterate over a list +loop $item in $ItemList +begin + -- Process each item +end loop; +``` + +### CASE with string values, `else`, or an alias + +`case … end case` **is supported** — see [CASE Statements (Enum Split)](#case-statements-enum-split) +above for the correct form. What is not supported is the SQL-flavoured spelling of +it: quoted values, an `else` fallback, and an `AS` alias all fail. + +```mdl +-- WRONG: case values are not string literals (parse error) +case $Order/Status + when 'Active' then set $Result = 1; +end case; + +-- WRONG: case values are not qualified (parse error) +case $Order/Status + when MyModule.Status.Active then set $Result = 1; +end case; + +-- WRONG: no AS alias (parse error: mismatched input 'as' expecting WHEN) +case $Order/Status as s + when Active then set $Result = 1; +end case; + +-- WRONG: no else branch (MDL008 → mxbuild CE0079 + CE0773) +case $Order/Status + when Active then set $Result = 1; + else set $Result = 0; +end case; + +-- CORRECT: bare enum values, one branch per value, including (empty) +case $Order/Status + when Active then set $Result = 1; + when Inactive then set $Result = 2; + when (empty) then set $Result = 0; +end case; +``` + +An enum split is the *only* thing `case` does — it branches on an enumeration, not +on arbitrary expressions. For anything else (a string comparison, a numeric range), +use nested `if … else … end if`. + +### TRY/CATCH Block + +```mdl +-- WRONG: TRY/CATCH not supported +TRY + commit $Order; +CATCH + log error 'Commit failed'; +end TRY; + +-- CORRECT: Use ON ERROR on specific activities +commit $Order on error { + log error 'Commit failed'; +}; +``` + +### BREAK/CONTINUE in Loops + +```mdl +-- WRONG: BREAK/CONTINUE not supported +loop $item in $ItemList +begin + if $item/Skip = true then + continue; -- NOT SUPPORTED + end if; + if $item/Stop = true then + break; -- NOT SUPPORTED + end if; +end loop; + +-- CORRECT: Use conditional logic +loop $item in $ItemList +begin + if $item/Skip = false and $item/Stop = false then + -- Process item + end if; +end loop; +``` + +### Reserved Words as Identifiers + +**Best practice: Always quote all identifiers** (attribute names, parameter names, entity names) with double quotes. This eliminates all reserved keyword conflicts and is always safe — quotes are stripped automatically by the parser. + +> **Exception — never quote `$`-prefixed variable/parameter references.** The quote +> rule is for *bare* names (entities, attributes, associations, declared parameter +> names). Variable/parameter **references** in expressions stay **unquoted**: +> `$Customer/Name`, `$currentObject`, `retrieve … from $List`. Quoting the `$` token +> (`"$Customer"`) breaks resolution. + +```mdl +create persistent entity Module."item" ( + "check": boolean default false, + "text": string(500), + "format": string(50), + "value": decimal, + "create": datetime, + "delete": datetime +); +``` + +Quoted identifiers also work for microflow parameter names: +```mdl +create microflow Module."Process" ("select": string, "type": integer) +begin + log info 'Processing'; + return; +end; +``` +## Common Studio Pro Errors + +| Error Code | Message | Fix | +|------------|---------|-----| +| CE0053 | Selected type is not allowed | Don't `declare` an object/list — get it from a parameter, retrieve, create, or loop (MDL043/MDL040) | +| CE0117 | Error in expression | Use qualified association names; use `not(expr)` not bare `not expr` | +| CE0104 | Action activity is unreachable | Remove code after RETURN | +| CE0105 | Must end with end event | Add RETURN statement | +| CE0008 | No action defined | Define action for activity | +| CW0094 | Variable never used | Remove unused variables or use them | +| MDL | Variable not declared | Use `declare $var type = value;` before SET | diff --git a/.claude/skills/mendix/write-oql-queries/SKILL.md b/.claude/skills/mendix/write-oql-queries/SKILL.md index d15274719..074d0dd3e 100644 --- a/.claude/skills/mendix/write-oql-queries/SKILL.md +++ b/.claude/skills/mendix/write-oql-queries/SKILL.md @@ -5,6 +5,12 @@ description: "Write OQL for Mendix VIEW entities — joins, aggregates, calculat # Skill: Write OQL Queries for Mendix VIEW Entities +## Reference files + +- [`reference/patterns.md`](reference/patterns.md) — the recurring OQL shapes + (aggregation, joins across associations, date bucketing, ranking, filtered + counts) and a full worked query, to adapt rather than derive. + ## Purpose Generate correct OQL (Object Query Language) queries for Mendix VIEW entities. This skill helps you create VIEW entities with proper OQL syntax that will execute successfully in Mendix runtime. @@ -321,204 +327,6 @@ left outer join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode - **Association traversal** (`alias/Module.Association/entity`): When joining on a Mendix-defined association - **JOIN ON** (`join entity on condition`): When joining on arbitrary conditions or non-association fields -## Common OQL Patterns - -### Pattern 1: Date-based Aggregation -```sql -create view entity Finance.MonthlySummary ( - Year: integer, - Month: integer, - TotalAmount: decimal, - TransactionCount: integer -) as ( - select - datepart(YEAR, t.Date) as Year, - datepart(MONTH, t.Date) as Month, - sum(t.Amount) as TotalAmount, - count(t.ID) as TransactionCount - from Finance.Transaction as t - where t.Status != 'VOIDED' - GROUP by datepart(YEAR, t.Date), datepart(MONTH, t.Date) -); -``` - -### Pattern 2: Conditional Aggregation -```sql -create view entity Finance.CategorySummary ( - Category: string(200), - Income: decimal, - Expense: decimal, - Net: decimal -) as ( - select - c.Name as Category, - sum(case when t.Type = 'INCOME' then t.Amount else 0 end) as Income, - sum(case when t.Type = 'EXPENSE' then t.Amount else 0 end) as Expense, - sum(case when t.Type = 'INCOME' then t.Amount - when t.Type = 'EXPENSE' then -t.Amount else 0 end) as Net - from Finance.Transaction as t - inner join Finance.Transaction_Category/Finance.Category as c - GROUP by c.Name -); -``` - -### Pattern 3: Association Navigation -```sql -create view entity Shop.OrderDetails ( - OrderId: long, - CustomerName: string(400), - TotalItems: integer, - TotalPrice: decimal -) as ( - select - o.OrderId as OrderId, - c.FirstName + ' ' + c.LastName as CustomerName, - count(ol.OrderLineId) as TotalItems, - o.TotalPrice as TotalPrice - from Shop.CustomerOrder as o - inner join Shop.Order_Customer/Shop.Customer as c - left join Shop.OrderLine_Order/Shop.OrderLine as ol - GROUP by o.OrderId, o.TotalPrice, c.FirstName, c.LastName -); -``` - -### Pattern 4: Calculations with Division -```sql -create view entity Finance.BudgetVariance ( - Category: string(200), - Budget: decimal, - Actual: decimal, - Variance: decimal, - VariancePercent: decimal -) as ( - select - c.Name as Category, - bl.PlannedAmount as Budget, - bl.ActualAmount as Actual, - bl.ActualAmount - bl.PlannedAmount as Variance, - (bl.ActualAmount - bl.PlannedAmount) * 100.0 : bl.PlannedAmount as VariancePercent - from Finance.BudgetLine as bl - inner join Finance.BudgetLine_Category/Finance.Category as c - where bl.PlannedAmount > 0 -); -``` - -### Pattern 5: IN Expression with Value List -```sql -create view entity Shop.HighPriorityTasks ( - TaskId: integer, - TaskTitle: string(200), - Priority: string(50) -) as ( - select - t.TaskId as TaskId, - t.TaskTitle as TaskTitle, - t.TaskPriority as Priority - from Shop.Task as t - where t.TaskPriority in ('HIGH', 'CRITICAL') -); -``` - -### Pattern 6: IN Expression with Subquery -```sql -create view entity Shop.CustomersWithOrders ( - CustomerId: integer, - CustomerName: string(200) -) as ( - select - c.CustomerId as CustomerId, - c.Name as CustomerName - from Shop.Customer as c - where c.CustomerId in ( - select distinct o.CustomerId - from Shop.Order as o - where o.Status = 'COMPLETED' - ) -); -``` - -### Pattern 7: Scalar Subquery in SELECT -```sql -create view entity Shop.ProductsAboveAverage ( - ProductId: integer, - Name: string(200), - Price: decimal, - PriceDifferenceFromAvg: decimal -) as ( - select - p.ProductId as ProductId, - p.Name as Name, - p.Price as Price, - p.Price - (select avg(p2.Price) from Shop.Product as p2) as PriceDifferenceFromAvg - from Shop.Product as p - where p.Price > (select avg(p3.Price) from Shop.Product as p3) -); -``` - -### Pattern 8: Correlated Subquery -```sql -create view entity Shop.OrdersWithCustomerStats ( - OrderId: integer, - OrderNumber: string(50), - CustomerTotalOrders: integer, - CustomerTotalSpend: decimal -) as ( - select - o.OrderId as OrderId, - o.OrderNumber as OrderNumber, - (select count(o2.OrderId) from Shop.Order as o2 where o2.CustomerId = o.CustomerId) as CustomerTotalOrders, - (select sum(o3.TotalAmount) from Shop.Order as o3 where o3.CustomerId = o.CustomerId) as CustomerTotalSpend - from Shop.Order as o -); -``` - -### Pattern 9: Correlated Subquery via Association -```sql --- Get the latest price for each product using association traversal -create view entity Shop.ProductCurrentPrice ( - ProductId: string(50), - Name: string(200), - PriceInEuro: decimal, - IsActive: boolean -) as ( - select - p.ProductId as ProductId, - p.Name as Name, - (select pr.PriceInEuro - from Shop.Price as pr - where pr.StartDate <= '[%BeginOfTomorrow%]' - and pr/Shop.Price_Product = p.ID - ORDER by pr.StartDate desc - limit 1) as PriceInEuro, - p.IsActive as IsActive - from Shop.Product as p - where p.IsActive -); -``` - -**Key points:** -- Use `pr/Shop.Price_Product = p.ID` (association path with `.ID`) -- Never use bare alias: `pr/Shop.Price_Product = p` will fail -- ORDER BY and LIMIT are valid inside correlated subqueries; at the view level, ORDER BY is allowed only when paired with LIMIT (MDL030) - -### Pattern 10: JOIN with ON Clause (Non-Association) -```sql --- When joining on arbitrary conditions (not Mendix associations) -create view entity Shop.ProductComparison ( - ProductId: integer, - ProductName: string(200), - CompetitorPrice: decimal -) as ( - select - p.ProductId as ProductId, - p.Name as ProductName, - cp.Price as CompetitorPrice - from Shop.Product as p - left join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode - where cp.CompetitorName = 'ACME' -); -``` - ## Step-by-Step Process ### Step 1: Define VIEW Entity Schema @@ -673,49 +481,6 @@ create view entity Finance.TopItems (...) as ( ); ``` -## Complete Example - -### User Request -"Create a VIEW entity showing monthly revenue with order statistics" - -### Response -```sql -/** - * Monthly revenue summary with order statistics - * - * Time-series view of revenue and order metrics - * aggregated by month and year. - * - * @since 1.0.0 - * @see Shop.CustomerOrder - */ -@position(1400, 450) -create view entity Shop.MonthlyRevenue ( - Year: integer, - Month: integer, - TotalOrders: integer, - TotalRevenue: decimal, - AverageOrderValue: decimal -) as ( - select - datepart(YEAR, o.OrderDate) as Year, - datepart(MONTH, o.OrderDate) as Month, - count(o.OrderId) as TotalOrders, - sum(o.TotalPrice) as TotalRevenue, - avg(o.TotalPrice) as AverageOrderValue - from Shop.CustomerOrder as o - GROUP by datepart(YEAR, o.OrderDate), datepart(MONTH, o.OrderDate) -); -``` - -### Why This Works -1. ✅ All columns have explicit AS aliases -2. ✅ Lowercase aggregates: `sum()`, `avg()`, `count()` -3. ✅ Proper COUNT: `count(o.OrderId)` not `count(*)` -4. ✅ Comma syntax for DATEPART: `datepart(YEAR, o.OrderDate)` -5. ✅ GROUP BY matches SELECT non-aggregated expressions -6. ✅ ORDER BY omitted (UI sorts) — or, for a top-N view, paired with a LIMIT (MDL030) - ## Testing OQL Queries Use `mxcli oql` to test queries against a running Mendix runtime (read-only preview mode): diff --git a/.claude/skills/mendix/write-oql-queries/reference/patterns.md b/.claude/skills/mendix/write-oql-queries/reference/patterns.md new file mode 100644 index 000000000..612a22846 --- /dev/null +++ b/.claude/skills/mendix/write-oql-queries/reference/patterns.md @@ -0,0 +1,243 @@ +# OQL patterns and a worked example + +Supporting reference for [write-oql-queries](../SKILL.md). + +## Common OQL Patterns + +### Pattern 1: Date-based Aggregation +```sql +create view entity Finance.MonthlySummary ( + Year: integer, + Month: integer, + TotalAmount: decimal, + TransactionCount: integer +) as ( + select + datepart(YEAR, t.Date) as Year, + datepart(MONTH, t.Date) as Month, + sum(t.Amount) as TotalAmount, + count(t.ID) as TransactionCount + from Finance.Transaction as t + where t.Status != 'VOIDED' + GROUP by datepart(YEAR, t.Date), datepart(MONTH, t.Date) +); +``` + +### Pattern 2: Conditional Aggregation +```sql +create view entity Finance.CategorySummary ( + Category: string(200), + Income: decimal, + Expense: decimal, + Net: decimal +) as ( + select + c.Name as Category, + sum(case when t.Type = 'INCOME' then t.Amount else 0 end) as Income, + sum(case when t.Type = 'EXPENSE' then t.Amount else 0 end) as Expense, + sum(case when t.Type = 'INCOME' then t.Amount + when t.Type = 'EXPENSE' then -t.Amount else 0 end) as Net + from Finance.Transaction as t + inner join Finance.Transaction_Category/Finance.Category as c + GROUP by c.Name +); +``` + +### Pattern 3: Association Navigation +```sql +create view entity Shop.OrderDetails ( + OrderId: long, + CustomerName: string(400), + TotalItems: integer, + TotalPrice: decimal +) as ( + select + o.OrderId as OrderId, + c.FirstName + ' ' + c.LastName as CustomerName, + count(ol.OrderLineId) as TotalItems, + o.TotalPrice as TotalPrice + from Shop.CustomerOrder as o + inner join Shop.Order_Customer/Shop.Customer as c + left join Shop.OrderLine_Order/Shop.OrderLine as ol + GROUP by o.OrderId, o.TotalPrice, c.FirstName, c.LastName +); +``` + +### Pattern 4: Calculations with Division +```sql +create view entity Finance.BudgetVariance ( + Category: string(200), + Budget: decimal, + Actual: decimal, + Variance: decimal, + VariancePercent: decimal +) as ( + select + c.Name as Category, + bl.PlannedAmount as Budget, + bl.ActualAmount as Actual, + bl.ActualAmount - bl.PlannedAmount as Variance, + (bl.ActualAmount - bl.PlannedAmount) * 100.0 : bl.PlannedAmount as VariancePercent + from Finance.BudgetLine as bl + inner join Finance.BudgetLine_Category/Finance.Category as c + where bl.PlannedAmount > 0 +); +``` + +### Pattern 5: IN Expression with Value List +```sql +create view entity Shop.HighPriorityTasks ( + TaskId: integer, + TaskTitle: string(200), + Priority: string(50) +) as ( + select + t.TaskId as TaskId, + t.TaskTitle as TaskTitle, + t.TaskPriority as Priority + from Shop.Task as t + where t.TaskPriority in ('HIGH', 'CRITICAL') +); +``` + +### Pattern 6: IN Expression with Subquery +```sql +create view entity Shop.CustomersWithOrders ( + CustomerId: integer, + CustomerName: string(200) +) as ( + select + c.CustomerId as CustomerId, + c.Name as CustomerName + from Shop.Customer as c + where c.CustomerId in ( + select distinct o.CustomerId + from Shop.Order as o + where o.Status = 'COMPLETED' + ) +); +``` + +### Pattern 7: Scalar Subquery in SELECT +```sql +create view entity Shop.ProductsAboveAverage ( + ProductId: integer, + Name: string(200), + Price: decimal, + PriceDifferenceFromAvg: decimal +) as ( + select + p.ProductId as ProductId, + p.Name as Name, + p.Price as Price, + p.Price - (select avg(p2.Price) from Shop.Product as p2) as PriceDifferenceFromAvg + from Shop.Product as p + where p.Price > (select avg(p3.Price) from Shop.Product as p3) +); +``` + +### Pattern 8: Correlated Subquery +```sql +create view entity Shop.OrdersWithCustomerStats ( + OrderId: integer, + OrderNumber: string(50), + CustomerTotalOrders: integer, + CustomerTotalSpend: decimal +) as ( + select + o.OrderId as OrderId, + o.OrderNumber as OrderNumber, + (select count(o2.OrderId) from Shop.Order as o2 where o2.CustomerId = o.CustomerId) as CustomerTotalOrders, + (select sum(o3.TotalAmount) from Shop.Order as o3 where o3.CustomerId = o.CustomerId) as CustomerTotalSpend + from Shop.Order as o +); +``` + +### Pattern 9: Correlated Subquery via Association +```sql +-- Get the latest price for each product using association traversal +create view entity Shop.ProductCurrentPrice ( + ProductId: string(50), + Name: string(200), + PriceInEuro: decimal, + IsActive: boolean +) as ( + select + p.ProductId as ProductId, + p.Name as Name, + (select pr.PriceInEuro + from Shop.Price as pr + where pr.StartDate <= '[%BeginOfTomorrow%]' + and pr/Shop.Price_Product = p.ID + ORDER by pr.StartDate desc + limit 1) as PriceInEuro, + p.IsActive as IsActive + from Shop.Product as p + where p.IsActive +); +``` + +**Key points:** +- Use `pr/Shop.Price_Product = p.ID` (association path with `.ID`) +- Never use bare alias: `pr/Shop.Price_Product = p` will fail +- ORDER BY and LIMIT are valid inside correlated subqueries; at the view level, ORDER BY is allowed only when paired with LIMIT (MDL030) + +### Pattern 10: JOIN with ON Clause (Non-Association) +```sql +-- When joining on arbitrary conditions (not Mendix associations) +create view entity Shop.ProductComparison ( + ProductId: integer, + ProductName: string(200), + CompetitorPrice: decimal +) as ( + select + p.ProductId as ProductId, + p.Name as ProductName, + cp.Price as CompetitorPrice + from Shop.Product as p + left join Shop.CompetitorProduct as cp on p.ProductCode = cp.ProductCode + where cp.CompetitorName = 'ACME' +); +``` +## Complete Example + +### User Request +"Create a VIEW entity showing monthly revenue with order statistics" + +### Response +```sql +/** + * Monthly revenue summary with order statistics + * + * Time-series view of revenue and order metrics + * aggregated by month and year. + * + * @since 1.0.0 + * @see Shop.CustomerOrder + */ +@position(1400, 450) +create view entity Shop.MonthlyRevenue ( + Year: integer, + Month: integer, + TotalOrders: integer, + TotalRevenue: decimal, + AverageOrderValue: decimal +) as ( + select + datepart(YEAR, o.OrderDate) as Year, + datepart(MONTH, o.OrderDate) as Month, + count(o.OrderId) as TotalOrders, + sum(o.TotalPrice) as TotalRevenue, + avg(o.TotalPrice) as AverageOrderValue + from Shop.CustomerOrder as o + GROUP by datepart(YEAR, o.OrderDate), datepart(MONTH, o.OrderDate) +); +``` + +### Why This Works +1. ✅ All columns have explicit AS aliases +2. ✅ Lowercase aggregates: `sum()`, `avg()`, `count()` +3. ✅ Proper COUNT: `count(o.OrderId)` not `count(*)` +4. ✅ Comma syntax for DATEPART: `datepart(YEAR, o.OrderDate)` +5. ✅ GROUP BY matches SELECT non-aggregated expressions +6. ✅ ORDER BY omitted (UI sorts) — or, for a top-N view, paired with a LIMIT (MDL030) diff --git a/cmd/mxcli/init_skills_references_test.go b/cmd/mxcli/init_skills_references_test.go new file mode 100644 index 000000000..412f516e6 --- /dev/null +++ b/cmd/mxcli/init_skills_references_test.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "io/fs" + "path" + "strings" + "testing" +) + +// A supporting file is inert unless SKILL.md links it: Claude loads the body, and +// only follows a link it can see. The Agent Skills docs are explicit — "Reference +// supporting files from SKILL.md so Claude knows what each file contains and when +// to load it" — so an unlinked reference file is dead weight that ships with every +// project and is never read. +// +// This is the one property the split of the six largest skills depends on. It +// cannot be checked by reading a single file, which is why it gets a test rather +// than a convention. +func TestEverySupportingFileIsLinkedFromItsSkill(t *testing.T) { + names := embeddedSkillNames(t) + + for _, skill := range names { + body, err := skillsFS.ReadFile("skills/" + skill + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", skill, err) + continue + } + text := string(body) + + var supporting []string + err = fs.WalkDir(skillsFS, "skills/"+skill, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || path.Base(p) == "SKILL.md" { + return err + } + if strings.HasSuffix(p, ".md") { + rel := strings.TrimPrefix(p, "skills/"+skill+"/") + supporting = append(supporting, rel) + } + return nil + }) + if err != nil { + t.Errorf("%s: walking: %v", skill, err) + continue + } + + for _, rel := range supporting { + if !strings.Contains(text, "("+rel+")") { + t.Errorf("%s/SKILL.md does not link %s — nothing will ever open it", skill, rel) + } + } + } +} + +// Splitting is only worth doing if the body actually got smaller. These six were +// 811–1906 lines, well past the documented "keep SKILL.md under 500 lines", and +// they are the ones agents load most often. The bound is deliberately loose: a +// body that is all essential beats one that hit a number by dropping something, +// and the heading-preservation check lives in the split itself. +func TestLargeSkillsWereSplit(t *testing.T) { + const bound = 700 + + for _, skill := range embeddedSkillNames(t) { + body, err := skillsFS.ReadFile("skills/" + skill + "/SKILL.md") + if err != nil { + t.Errorf("%s: %v", skill, err) + continue + } + if n := strings.Count(string(body), "\n"); n > bound { + t.Errorf("%s/SKILL.md is %d lines (> %d) and has no supporting files to move detail into; "+ + "a body this long is loaded whole every time the skill is used", skill, n, bound) + } + } +} From d8cd6fb448d54bfb91e994c6b23b6c5e41471213 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:54:27 +0000 Subject: [PATCH 7/7] fix(skills): one frontmatter block per skill, and name the build step that was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from a real verification run against a two-app Mendix solution (mxcli-formula1 finding 68). 1. `custom-widgets/SKILL.md` shipped with TWO frontmatter blocks. The #906 migration prepended a `name`/`description` block to all 67 skills, including the one file that already had one under its pre-rename name (`mendix-custom-widgets`). Everything after the first `---` block is body, so the old block rendered as a stray fence and a duplicate `name`. The two are merged, keeping the old block's better trigger words — child slots (TEMPLATE/FILTER), `.def.json`, engine internals — which the migration had dropped. The pre-state was known: 1 of 68 files already had frontmatter was measured and written down before the migration ran, and the script still did not branch on it. 2. Nothing could see it. `TestEmbeddedSkillsCarryAgentSkillsFrontmatter` matches the FIRST block and validates it, which is correct — and precisely why a second block is invisible. A rename-and-prepend is exactly the shape of change that produces one, so the test now rejects a second block outright rather than checking the first one harder. 3. `make test` did not depend on `sync-all` while `make build` did. The embed dirs under cmd/mxcli are generated from .claude/, so a bare `go build` after the skills layout changed left `cmd/mxcli/skills` stale and failed six tests — and the failure said "the embed directive is broken", naming a directive that was fine instead of the build step that was missing. `make test` now syncs, and the message names `cmd/mxcli/skills` and `make sync-skills`. Verified by reproduction: emptying `cmd/mxcli/skills` now prints the new message, and `make test` restores the directory and passes on its own. The frontmatter guard fails against the unfixed file and passes after. The report attributed the first defect to PR #222. It was not: that diff touches only the `description:` inside the first block, and the stray `---` appears in it as unchanged context. `git log -S 'name: mendix-custom-widgets'` places it in the migration commit. Recorded in the symptom table, because the reasoning error — blaming the change you happen to be looking at — is the reusable part. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017s476QkXr9CFMvKspVzcvu --- .claude/skills/fix-issue.md | 1 + .claude/skills/mendix/custom-widgets/SKILL.md | 7 +------ Makefile | 8 +++++++- cmd/mxcli/init_skills_standard_test.go | 12 ++++++++++++ cmd/mxcli/init_skills_sync_test.go | 8 +++++++- 5 files changed, 28 insertions(+), 8 deletions(-) diff --git a/.claude/skills/fix-issue.md b/.claude/skills/fix-issue.md index 329f54cbc..db9e64e5c 100644 --- a/.claude/skills/fix-issue.md +++ b/.claude/skills/fix-issue.md @@ -611,3 +611,4 @@ extracting `OffsetExpression`/`LimitExpression`. | `CREATE OR MODIFY ODATA CLIENT` reports `Unchanged OData client` after the contract file was refreshed from the running backend — `SHOW CONTRACT ENTITIES` keeps listing the old entity types, and the `CREATE OR MODIFY EXTERNAL ENTITIES` that follows imports the old shape with no warning. `DROP ODATA CLIENT` + recreate is the only way out, and it invalidates the client ID the existing external entities point at | The modify branch updated every property **except the cached contract**. Only the create path fetched `$metadata`; `svc.Metadata` / `svc.MetadataHash` kept the snapshot taken when the client was first created. The same branch also stored `stmt.MetadataUrl` raw, skipping the `NormalizeURL` the create path applies, so a relative `./contracts/x.xml` ended up as a URL neither Studio Pro nor `fetchODataMetadata` can open | `mdl/executor/cmd_odata.go` (`refreshCachedMetadata`, `normalizeMetadataURL`, `contractSummary`, called from the `stmt.CreateOrModify` branch of `createODataClient`); test `mdl/executor/cmd_odata_metadata_refresh_test.go`; repro `mdl-examples/bug-tests/odata-client-stale-contract-on-modify.mdl` | **Refresh from the *effective* URL, not the statement's** — a `CREATE OR MODIFY` that omits `MetadataUrl` still has to converge the cache, or the idiomatic re-run silently keeps the stale contract. **A failed fetch must keep what is cached** (warning, not error): losing a contract that was merely unreachable is worse than serving a stale one, and there is nothing to fall back on. **Leave `svc` untouched when the hash matches**, or ADR-0008 elision breaks and every re-run churns the document — the unchanged-contract control is what proves this, and it passes both before and after the fix, so it is the half of the test that keeps the other half honest. `ALTER ODATA CLIENT SET MetadataUrl` has the same staleness and is deliberately **not** fixed here: it carries no design-time credentials, so a blind re-fetch of an authenticated URL would warn where it used to be silent. `mx check` is no oracle for any of this — the stale project builds with 0 errors, because a client caching an old contract is perfectly valid | | A microflow rewritten by `CREATE OR MODIFY MICROFLOW` is drawn with its **StartEvent stranded** — every activity where the script asked, the start left behind, joined to its own first activity by a long mostly-empty line across the canvas. Reproduces on every rewrite, and copying a working script's `@position` pattern does not help | The fix for the OPPOSITE report. #884 was a describe→exec round-trip MOVING a hand-placed start (145;200 → 100;200), fixed by carrying the stored position over on every rewrite; that then **pinned** the start of every rewritten flow, so it no longer followed activities the same script had just moved. Both reports are real and neither is answerable without asking where the stored value came from | `mdl/executor/cmd_microflows_start_position.go` (`authoredStartPosition`, `derivedStartPosition`, `startAnnotationLines`), `mdl/executor/cmd_microflows_create.go` (`storedStartPosition`), `mdl/executor/cmd_microflows_builder_graph.go` (StartEvent construction) | Carry the stored position over **only when it is not where the layout would have put it** — a start at `first.X − spacing` on `first.Y` is mxcli's own arithmetic handed back and carries no intent, so re-derive it; anywhere else, a person put it there, so keep it. Add `@start(x, y)` on the first statement (the `@merge` precedent — it positions the other node with no statement of its own) so the position can be *stated* rather than inferred, and have DESCRIBE emit it **only for a non-derived start**, which is what makes the round-trip exact without pinning every described flow. **`mx check` is not the oracle, and neither is a green build**: the Mendix model carries no geometry rules, so a stranded start is a valid document — 0 errors on mxbuild 11.13.0 before and after. Verify by reading the coordinates back off the stored document. **Emit from BOTH describers**: `formatMicroflowActivities` and `formatMicroflowActivitiesWithSourceMap` are near-duplicates, and the first cut of this fix patched only the second, so `describe microflow` silently dropped the line. Controls: reverting the narrowing reproduces the stranded start, reverting the builder arm reproduces "@start does nothing", reverting either describer reproduces the lossy round-trip. Repro `mdl-examples/bug-tests/951-microflow-start-event-position.mdl`. Issue #951 | | `SPLIT TYPE … END SPLIT` followed by any statement draws that statement **on top of the merge** in Studio Pro, joined by a zero-length sequence flow, and the merge itself sits far to the right of the branches it joins (branches at x=720, merge at x=1480). `CASE` and `IF/ELSE` with the same graph shape lay out correctly. `mx check` reports 0 errors either way | Two defects in `addStructuredInheritanceSplit`. (1) It ended with `fb.posX = mergeX` — the merge's own centre — where `addEnumSplit` steps to `mergeX + HorizontalSpacing/2` and `addIfStatement` to `mergeX + MergeSize + HorizontalSpacing/2`; three builders, three conventions, and the type split's was zero. (2) Branch width came from `measureStatements(appendInheritanceBodies(s))`, which concatenated every branch body into ONE list and measured it as a single left-to-right run — so the merge slid right by an activity-plus-spacing per **extra branch**, not per branch *width*. `layout.go`'s own `measureInheritanceSplitStatement` always took the max, so the builder disagreed with its measurer | `mdl/executor/cmd_microflows_builder_actions.go` (`addStructuredInheritanceSplit`; `appendInheritanceBodies` → `inheritanceBranchBodies`); tests `mdl/executor/cmd_microflows_builder_split_geometry_test.go`; repro `mdl-examples/bug-tests/953-split-type-merge-overlap.mdl` | **Geometry is invisible to every automatic check below Studio Pro** — the model is valid, so `mx check` passes, the build passes, and `describe` output looks right until you read the coordinates. `describe` printing `@position`/`@merge` IS the headless oracle: diff those, don't open the modeler. **The split builders had zero positional coverage** before this (grep `Position` in the enum-split and inheritance-split tests: nothing), which is the same gap behind the loop-box sizing bugs #790 and #884. **Pick the IF convention, not the CASE one**: the constants are centre-to-centre for a 40px edge gap, and `CASE`'s `+HorizontalSpacing/2` leaves a following activity's left edge exactly touching the merge (measured: merge 890, activity 970, both edges at 910) — a lesser pre-existing nit deliberately left alone, because changing it re-lays-out every enum split ever written. **`@merge(x,y)` was not a workaround**: `fb.posX = mergeX` read the overridden value, so moving the merge by hand moved the stacked element with it. A fix needs a control that the neighbouring construct did NOT move — `TestEnumSplitGeometryIsUnchanged` — or "the type split matches CASE now" can be achieved by breaking CASE. Issue #953 | +| A bundled skill renders with a stray `---` fence and a duplicated `name:` at the top of its body, and `mxcli init` ships it that way. `TestEmbeddedSkillsCarryAgentSkillsFrontmatter` passes — the frontmatter it checks is valid | The #906 migration **prepended** a `name`/`description` block to every skill, including the one file that already had one (`custom-widgets`, under its pre-rename name `mendix-custom-widgets`). Everything after the FIRST `---` block is body, so the old block became visible text. The test's `frontmatter.FindSubmatch` matches the first block and validates it correctly, which is precisely why it is blind to a second one | `.claude/skills/mendix/custom-widgets/SKILL.md` (two blocks merged into one, keeping the old block's trigger words — child slots, `.def.json`, engine internals); guard added to `cmd/mxcli/init_skills_standard_test.go` | **A first-block check cannot see a rename-and-prepend**, and that is the exact shape of a migration that renames things — so a bulk frontmatter edit needs a guard against a SECOND block, not a better check of the first. **Measure the pre-state before a bulk prepend**: 1 of 68 files already had frontmatter, which was known and written down, and the migration script still did not branch on it. **Do not attribute a defect to the change you are looking at** — this was reported against PR #222, whose diff touches only the `description:` inside the first block and shows the stray `---` as unchanged context; `git log -S 'name: mendix-custom-widgets'` settles it in one command. Two adjacent traps from the same report: `make test` did not depend on `sync-all` while `make build` did, so a bare `go build` left the generated `cmd/mxcli/skills` stale and six tests failed — and the failure message named the `go:embed` directive, which was fine, instead of the missing build step. A **differential across refs is only as sound as the procedure both sides share**: the same stale build made main fail 4 and the branch fail 6, which looked like a regression and was a test-count difference. mxcli-formula1 finding 68 | diff --git a/.claude/skills/mendix/custom-widgets/SKILL.md b/.claude/skills/mendix/custom-widgets/SKILL.md index deb5eb8e5..e068b0195 100644 --- a/.claude/skills/mendix/custom-widgets/SKILL.md +++ b/.claude/skills/mendix/custom-widgets/SKILL.md @@ -1,11 +1,6 @@ --- name: custom-widgets -description: "MDL syntax for pluggable widgets already installed in a project — Gallery, DataGrid2, ComboBox and the rest, including their datasource and column forms. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting." ---- - ---- -name: mendix-custom-widgets -description: Use when writing MDL for GALLERY, COMBOBOX, or third-party pluggable widgets in CREATE PAGE / ALTER PAGE statements. Covers built-in widget syntax, child slots (TEMPLATE/FILTER), adding new custom widgets via .def.json, and engine internals. +description: "MDL syntax for pluggable widgets in CREATE PAGE / ALTER PAGE — GALLERY, COMBOBOX, DataGrid2 and third-party widgets: datasource and column forms, child slots (TEMPLATE/FILTER), adding a widget via .def.json, and the engine internals. Use when placing a pluggable widget on a page, or when `mxcli widget describe` output needs interpreting." --- # Custom & Pluggable Widgets in MDL diff --git a/Makefile b/Makefile index ff131c97f..04843449d 100644 --- a/Makefile +++ b/Makefile @@ -158,7 +158,13 @@ release: clean grammar vscode-ext sync-all @ls -lh $(BUILD_DIR)/ # Run tests -test: grammar +# `sync-all` is not optional here. The embed dirs (cmd/mxcli/skills, skillpacks, +# commands, lint-rules) are GENERATED from .claude/, and several tests read them +# back. `make build` has always synced first; `make test` did not, so a checkout +# where the skills layout had changed under a bare `go build` failed six tests in +# cmd/mxcli with an error that pointed at the go:embed directive rather than at +# the missing build step (mxcli-formula1 finding 68). +test: grammar sync-all CGO_ENABLED=0 go test ./... # Dual-engine read-parity harness: run read queries through the legacy (sdk/mpr) diff --git a/cmd/mxcli/init_skills_standard_test.go b/cmd/mxcli/init_skills_standard_test.go index a2af10151..cdd62665e 100644 --- a/cmd/mxcli/init_skills_standard_test.go +++ b/cmd/mxcli/init_skills_standard_test.go @@ -40,6 +40,18 @@ func TestEmbeddedSkillsCarryAgentSkillsFrontmatter(t *testing.T) { } fm := string(m[1]) + // A stray SECOND frontmatter block is invisible to every check below: + // FindSubmatch matches the first one, and that one is correct. It is + // exactly what a rename-and-prepend leaves behind — the #906 migration + // prepended a block to `custom-widgets`, which already had one, so the + // skill shipped opening with a duplicate `name` and a `---` fence + // rendered as body. Nothing in the suite could see it + // (mxcli-formula1 finding 68). + if body := string(body[len(m[0]):]); strings.HasPrefix(strings.TrimLeft(body, "\n"), "---\n") { + t.Errorf("%s/SKILL.md has a second frontmatter block; everything after the first `---` "+ + "is body, so it renders as a stray fence and a duplicate name", n) + } + var gotName, gotDesc string for _, line := range strings.Split(fm, "\n") { if v, ok := strings.CutPrefix(line, "name: "); ok { diff --git a/cmd/mxcli/init_skills_sync_test.go b/cmd/mxcli/init_skills_sync_test.go index 9af19c349..d47fddb7b 100644 --- a/cmd/mxcli/init_skills_sync_test.go +++ b/cmd/mxcli/init_skills_sync_test.go @@ -31,7 +31,13 @@ func embeddedSkillNames(t *testing.T) []string { names = append(names, e.Name()) } if len(names) == 0 { - t.Fatal("no embedded skills; the embed directive is broken") + // Naming the go:embed line here sent a reviewer hunting a directive that + // was fine: `cmd/mxcli/skills` is GENERATED, and a bare `go build` after + // the layout changed leaves it stale or empty. Name the build step + // instead (mxcli-formula1 finding 68). + t.Fatal("no embedded skills: cmd/mxcli/skills is empty or stale. " + + "It is generated from .claude/skills/mendix/ — run `make sync-skills` " + + "(or `make build`, which does it) before `go test`.") } return names }