From 745d24ee5d0c05ea92326ddda9594507f6e797dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 18:20:09 +0000 Subject: [PATCH 1/7] fix: flatten OData ComplexType properties on external entity import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CREATE OR MODIFY EXTERNAL ENTITIES FROM dropped every property whose type was an OData ComplexType. The entity imported with only its key, `exec` reported "1 created, 0 failed" and printed nothing, and `mx check` said 0 errors — the loss surfaced later as CE1613 on a page written against the attributes Studio Pro would have made (mendixlabs/mxcli#1118). mdl/types/edmx.go never parsed at all, so a property typed `Shared.Uom.Quantity` was indistinguishable from one of an unknown type and fell through createExternalEntities' `!strings.HasPrefix(p.Type, "Edm.")` drop. The report says "only cross-namespace"; in fact every complex type was dropped, since nothing named `Edm.*` is complex. Flatten as Studio Pro does: one attribute per leaf, named `_`, read over the OData path `/`. Complex types resolve by QUALIFIED name — one document may declare `Quantity` in two namespaces, and a short-name lookup would hand over the other schema's properties. Measured on mxbuild 11.12.1, three copies of one project: RemoteName 'MaxQty/UoMNId' -> The app contains: 0 errors. RemoteName 'MaxQty_UoMNId' -> 4x CE6615 "does not exist in the OData service" RemoteName 'MaxQty_ZZNOTAPATH_…' -> 4x CE6615 so mxbuild resolves the path into the complex type and genuinely validates it. The pre-fix control — attributes simply absent — is 0 errors, which is why the drop was invisible. Flattened attributes are written read-only. Against a contract annotated Insertable=true AND Updatable=true, Mendix still reports them Creatable=False/Updatable=False, so following the entity set costs 2x CE6630 per attribute; this matches Mendix's documented "can only be read or deleted". Whatever still cannot be mapped — a complex type nested in a complex type, an unresolvable type, Edm.Duration — is now named with a reason instead of vanishing. DESCRIBE CONTRACT ENTITY flattens too, replacing the `String(200)` catch-all that masked the complex property in both its output formats. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019FYdKPGnoWiq2VgNF6ZfX1 --- .../fix-issue/findings/mdl-executor.jsonl | 1 + docs-site/src/reference/odata/README.md | 24 ++ .../1118-odata-complextype-flattening.mdl | 55 ++++ .../complextype-metadata.xml | 31 ++ mdl/executor/cmd_contract.go | 100 ++++-- mdl/executor/cmd_contract_complextype_test.go | 288 ++++++++++++++++++ mdl/types/edmx.go | 208 +++++++++++-- mdl/types/edmx_complextype_test.go | 133 ++++++++ 8 files changed, 795 insertions(+), 45 deletions(-) create mode 100644 mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl create mode 100644 mdl-examples/odata-local-metadata/complextype-metadata.xml create mode 100644 mdl/executor/cmd_contract_complextype_test.go create mode 100644 mdl/types/edmx_complextype_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index fdf81448e0..1d427e1949 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -637,3 +637,4 @@ {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A .def.json that maps a Data Grid 2 column filter's `linkedDs` produces a widget mxbuild rejects with CE0642 \"Property 'Datasource to Filter' is required\" — naming the very property the value was written into", "cause": "`linkedDs` is declared `isLinked=\"true\"` in widget.xml: the platform fills it from the containing DataGrid2, and mxbuild resolves it from the parent rather than reading what is stored. mxcli could not tell a linked datasource from an authorable one because IsLinked, though present in the template ValueType, was not carried into PropertyTypeIDEntry", "file": "`mdl/types/widget_property_type.go` (IsLinked), `modelsdk/widgets/loader.go`, `mdl/executor/widget_engine.go` (`refuseLinkedDataSourceMapping`)", "insight": "Before mapping a widget property, check `isLinked` in widget.xml — a linked property is the platform's to fill, the ADR-0005 'author only what the model owns' rule wearing a widget hat. Three cheap measurements settle it faster than reasoning: grep the shipped template's ValueType for IsLinked, dump the property off Studio Pro-authored widgets in testdata/expr-checker (5 of 5 store linkedDs empty), and mx check the correct shape (0 errors WITHOUT it). Beware the inverted signal: writing the value does NOT clear CE0642, so a failing check after writing it looks like the value is missing rather than unwanted. Across all widget packages in testdata, linkedDs is the ONLY linked datasource among the 8 multi-datasource widgets — DROPDOWNFILTER is single-source from MDL's side, ComboBox and the 6 charts are genuinely multi-source", "ce": ["CE0642"]} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A chart series given BOTH a static and a dynamic datasource writes its static x/y attributes against the DYNAMIC source's entity — mxbuild reports CE1613 \"The selected attribute 'CH.Forecast.Region' no longer exists.\"", "cause": "buildObjectListItem pre-resolves every datasource the item configures and dropped each resolved entity into the one shared pageBuilder.entityContext, so the LAST one won. The per-property link was already in hand and ignored: ItemPropertyMapping.DataSource carries widget.xml's `dataSource=\"...\"` and GenerateDefJSON already emits it for every chart dependent", "file": "`mdl/executor/widget_engine.go` (`itemEntityContextFor`, `prebuiltEntities` in `buildObjectListItem`)", "insight": "The item twin of the widget-level per-datasource context (#1109). Look for the SECOND copy whenever a context fix lands at widget level — object-list items run the same pre-resolve/resolve shape with their own loop. The shipped chart defs already map staticDataSource AND dynamicDataSource with every dependent's link, so nothing needed mapping; the links simply were not read. Note the weak in-repo signals: `mxcli check` only warns (MDL-WIDGET10, the inactive set is hidden) and the describe output looks right, so the defect is visible only in the stored BSON or from mxbuild. Charts' static/dynamic sit INSIDE the `lines` object list, not at widget level — a recursive widget.xml scan makes them look like widget properties", "ce": ["CE1613"]} {"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY MICROFLOW` re-enables concurrent execution on a microflow that disallowed it \u2014 the running app's concurrency protection removed \u2014 and drops the concurrency error message (all translations) and error microflow, plus `MarkAsUsed`. Every checker is green: **CE4899 fires only on disallow-without-a-message, never on allow**, so the one error that exists in this area is exactly the one the reset switches off", "cause": "`buildMicroflowFromStmt` built the rebuild struct with `AllowConcurrentExecution: true` and `MarkAsUsed: false` literals, and `microflowToGen` wrote `SetConcurrencyErrorMicroflowQualifiedName(\"\")` + a bare `genTexts.NewText()`. The backend already READ the two flags back (the #723 \u00a7A fix), so the round-trip test passed while the bug was live \u2014 the executor overwrote them before the backend ever saw them", "file": "`mdl/executor/cmd_microflows_build.go` (buildMicroflowFromStmt), `mdl/backend/modelsdk/microflow_write.go` (microflowToGen), `mdl/backend/modelsdk/microflow.go` (microflowFromGen), `sdk/microflows/microflows.go`", "fix": "Carry all four from the stored microflow, seeding the locals with the NEW-microflow defaults (true/false) so no separate preserve flag is needed. The error message reuses the existing `textFromGen`/`textToGen` pair, so translations survive; nil still emits the bare empty `Texts$Text` the writer always wrote", "insight": "**A passing round-trip test at one layer says nothing about the layer above it.** `TestMicroflowRoundTrip_ConcurrentExecutionFlags` had guarded these two flags since #723 and was green throughout, because the executor's rebuild struct overwrites them before calling the backend. When a property is reset, locate the LAST writer on the path, not the first one that looks responsible. **And check which way a reset goes**: #723's backend bug wrote the Go zero value (allow -> disallow) and hit CE4899 immediately; the executor's literal writes the opposite (disallow -> allow), and the same CE4899 that caught the first direction is structurally blind to the second. A checker that catches a property's loss in one direction is not coverage for that property. Two methodological traps in the test itself, both hit: `bytes.Equal` on two encodes of the same microflow ALWAYS differs (fresh random sub-element `$ID`s \u2014 the reason `canon` exists), and `canon.Equal` on a whole microflow always differs too, because `StableId` is a fresh GUID *value* per encode and `Equal` does not mask \u2014 only `Reconcile` may be asked that question. Compare the sub-element under test, or use Reconcile. Controls: hardcoding the executor literals back, emptying the writer's pair, and stubbing the reader each fail a different test with the reported symptom"} +{"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY EXTERNAL ENTITIES FROM` imports an OData entity with **none** of its ComplexType properties — `describe entity` lists only the key. `exec` reports `1 created, 0 failed` and prints nothing; `mx check` says 0 errors. The loss surfaces much later as CE1613 on a page written against the attributes Studio Pro would have made. `DESCRIBE CONTRACT ENTITY` compounded it by reporting the complex property as `String(200)`", "cause": "`mdl/types/edmx.go` never parsed `` at all, so a property typed `Shared.Uom.Quantity` was indistinguishable from one of an unknown type, and `createExternalEntities`' `if !strings.HasPrefix(p.Type, \"Edm.\")` dropped it with no `continue` message. `String(200)` was `edmToMendixType`'s default branch", "file": "`mdl/types/edmx.go` (EdmComplexType, FindComplexType, FlattenProperties, EdmProperty.RemotePath/Path), `mdl/executor/cmd_contract.go` (createExternalEntities, describeContractEntity, outputContractEntityMDL)", "insight": "**The local name and the remote name differ by SEPARATOR, and that is the whole fix.** Studio Pro names the attribute `MaxQty_UoMNId` and reads it over the OData path `MaxQty/UoMNId`; assuming RemoteName == attribute name is the obvious wrong turn and it is silent in the model. Measured on mxbuild 11.12.1, three copies of one project: RemoteName `MaxQty/UoMNId` -> 0 errors; `MaxQty_UoMNId` -> 4x **CE6615** \"Attribute 'X' of external entity 'Definition' does not exist in the OData service\"; a deliberately bogus path -> the same 4x CE6615. So mxbuild resolves the path INTO the complex type and genuinely validates it — the 0-error run is evidence, not a rubber stamp, and CE6615 is the detector to reach for on any external-entity remote-name question. Two more measurements worth not repeating: (1) the pre-fix control — attributes simply absent — is **0 errors**, so the build never catches the drop itself, only a later reference does; a regression test asserting `mx check` clean would have passed against the bug. (2) A flattened attribute is Creatable=False AND Updatable=False *whatever the entity set says*: against a contract annotated `Insertable=true`+`Updatable=true`, following the entity set costs 2x **CE6630** per attribute, matching Mendix's doc that entities with complex attributes 'can only be read or deleted'. Resolve complex types by QUALIFIED name — one document may declare `Quantity` in two namespaces, and FindEntityType's short-name fallback would silently hand over the other schema's properties. The report said 'only cross-namespace'; in fact every complex type was dropped, since nothing named `Edm.*` is complex — the reporter's service just happened to declare them elsewhere. Repro `mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl`", "file_refs": ["mdl/types/edmx.go", "mdl/executor/cmd_contract.go"], "refs": ["mendixlabs/mxcli#1118"], "ce": ["CE6615", "CE6630", "CE1613"]} diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index 20a683ccdc..1be472fb23 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -45,6 +45,30 @@ CREATE EXTERNAL ENTITIES FROM Module.Service ENTITIES (Customer, Order); CREATE OR MODIFY EXTERNAL ENTITIES FROM Module.Service; ``` +### Complex types are flattened + +The Mendix domain model has no complex types. A property typed as an OData +`ComplexType` is imported as one attribute per leaf — the same thing Studio Pro +does — named `_` and read over the OData path `/`: + +| $metadata | Mendix attribute | Remote name | +|-----------|------------------|-------------| +| `MaxQty` of type `Shared.Uom.Quantity` { `UoMNId`, `QuantityValue` } | `MaxQty_UoMNId`, `MaxQty_QuantityValue` | `MaxQty/UoMNId`, `MaxQty/QuantityValue` | + +The complex type may live in any `Schema` in the document — it is resolved by +qualified name, so two namespaces may each declare a `Quantity`. + +Two consequences worth knowing: + +- **Flattened attributes are read-only.** Mendix treats an external entity that + contains them as readable and deletable only, whatever the entity set's + `InsertRestrictions` / `UpdateRestrictions` say. Marking them creatable or + updatable is `CE6630`. +- **Flattening is one level deep.** A complex type nested inside a complex type + is not an importable attribute. It is *reported*, along with anything else the + import could not map — an import that drops a property now says which one and + why, rather than reporting success. + ## Contract Browsing Statements Browse available assets from cached service contracts without network access. diff --git a/mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl b/mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl new file mode 100644 index 0000000000..7401f33f3b --- /dev/null +++ b/mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl @@ -0,0 +1,55 @@ +-- Bug mendixlabs/mxcli#1118: CREATE OR MODIFY EXTERNAL ENTITIES FROM silently +-- drops OData ComplexType properties. +-- +-- Symptom, verbatim: "mxcli silently discards any OData property whose type is a +-- ComplexType defined in a different Schema namespace within the same $metadata +-- document. The attributes are not created in the Mendix entity, and no warning +-- or error is emitted." Pages and microflows written against the attributes +-- Studio Pro WOULD have made then fail the build with CE1613. +-- +-- Cause: mdl/types/edmx.go never parsed at all, so a property +-- typed `Shared.Uom.Quantity` was indistinguishable from one of an unknown type, +-- and createExternalEntities' `if !strings.HasPrefix(p.Type, "Edm.")` dropped it +-- without a word. The report says "only cross-namespace" — in fact EVERY complex +-- type was dropped, same namespace or not; nothing named `Edm.*` is complex. +-- +-- Fix: flatten as Studio Pro does — one attribute per leaf, named +-- `_`, read over the OData path `/`. +-- +-- Measured on mxbuild 11.12.1, three copies of one project: +-- +-- RemoteName 'MaxQty/UoMNId' -> The app contains: 0 errors. +-- RemoteName 'MaxQty_UoMNId' -> 4 x CE6615 "does not exist in the OData service" +-- RemoteName 'MaxQty_ZZNOTAPATH_…' -> 4 x CE6615 +-- +-- and the pre-fix control (attributes simply absent) -> 0 errors, which is why +-- the loss was invisible until something referenced them. +-- +-- Run it: +-- cp mdl-examples/odata-local-metadata/complextype-metadata.xml /path/to/app/ +-- mxcli exec 1118-odata-complextype-flattening.mdl -p app.mpr + +create module Issue1118; +create module role Issue1118.User; + +create constant Issue1118.SvcUrl + type string + default 'https://example.com/odata/v4/App/'; + +create odata client Issue1118.App ( + ODataVersion: 'OData4', + MetadataUrl: './complextype-metadata.xml', + ServiceUrl: '@Issue1118.SvcUrl' +); + +-- The contract's own view: MaxQty/MaxWeight must appear FLATTENED here, not as +-- `String(200)` — the lossy catch-all the report calls out. +describe contract entity Issue1118.App.Definition; + +create or modify external entities from Issue1118.App into Issue1118 entities (Definition); + +-- Expect DefinitionId plus six flattened attributes: +-- MaxQty_UoMNId: String(40), MaxQty_QuantityValue: Decimal, +-- MaxWeight_UoMNId: String(40), MaxWeight_QuantityValue: Decimal +-- Before the fix this listed DefinitionId and nothing else. +describe entity Issue1118.Definition; diff --git a/mdl-examples/odata-local-metadata/complextype-metadata.xml b/mdl-examples/odata-local-metadata/complextype-metadata.xml new file mode 100644 index 0000000000..4ae99fe07f --- /dev/null +++ b/mdl-examples/odata-local-metadata/complextype-metadata.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mdl/executor/cmd_contract.go b/mdl/executor/cmd_contract.go index d66fc3dde0..7bbc5e1aaf 100644 --- a/mdl/executor/cmd_contract.go +++ b/mdl/executor/cmd_contract.go @@ -171,10 +171,16 @@ func describeContractEntity(ctx *ExecContext, name ast.QualifiedName, format str } fmt.Fprintln(ctx.Output) - // Properties + // Properties, with complex types expanded the way an import would expand + // them. Reporting `MaxQty Shared.Uom.Quantity` — or, in the MDL form, + // `MaxQty: String(200)` from edmToMendixType's catch-all — describes an + // attribute that cannot exist and hides the ones that will + // (mendixlabs/mxcli#1118). + props, unsupported := doc.FlattenProperties(et.Properties) + nameWidth := len("Property") typeWidth := len("Type") - for _, p := range et.Properties { + for _, p := range props { if len(p.Name) > nameWidth { nameWidth = len(p.Name) } @@ -186,13 +192,20 @@ func describeContractEntity(ctx *ExecContext, name ast.QualifiedName, format str fmt.Fprintf(ctx.Output, " %-*s %-*s %s\n", nameWidth, "Property", typeWidth, "Type", "Nullable") fmt.Fprintf(ctx.Output, " %s %s %s\n", strings.Repeat("-", nameWidth), strings.Repeat("-", typeWidth), "--------") - for _, p := range et.Properties { + for _, p := range props { nullable := "Yes" if p.Nullable != nil && !*p.Nullable { nullable = "No" } fmt.Fprintf(ctx.Output, " %-*s %-*s %s\n", nameWidth, p.Name, typeWidth, formatEdmType(p), nullable) } + if len(unsupported) > 0 { + fmt.Fprintln(ctx.Output) + fmt.Fprintln(ctx.Output, " Not importable as attributes:") + for _, u := range unsupported { + fmt.Fprintf(ctx.Output, " %s\n", u) + } + } // Navigation properties if len(et.NavigationProperties) > 0 { @@ -287,7 +300,8 @@ func outputContractEntityMDL(ctx *ExecContext, et *types.EdmEntityType, svcQN st fmt.Fprintln(ctx.Output, ")") fmt.Fprintln(ctx.Output, "(") - for i, p := range et.Properties { + props, _ := doc.FlattenProperties(et.Properties) + for i, p := range props { // Skip ID properties that are not real attributes isKey := false for _, k := range et.KeyProperties { @@ -305,13 +319,13 @@ func outputContractEntityMDL(ctx *ExecContext, et *types.EdmEntityType, svcQN st attrName := attrNameForOData(p.Name, et.Name) mendixType := edmToMendixType(p) comma := "," - if i == len(et.Properties)-1 { + if i == len(props)-1 { comma = "" } // When the OData property name was renamed (reserved word), show the // original OData name as a comment so the user knows the mapping. - if attrName != p.Name { - fmt.Fprintf(ctx.Output, " -- OData property: %s\n", p.Name) + if attrName != p.Path() { + fmt.Fprintf(ctx.Output, " -- OData property: %s\n", p.Path()) } fmt.Fprintf(ctx.Output, " %s: %s%s\n", attrName, mendixType, comma) } @@ -532,6 +546,12 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) // Reported at the end so the local name never silently diverges from the // contract; the mapping still points at the remote property either way. var renamed []string + // Contract properties that could not become attributes, with the reason. + // Reported at the end: an import that drops a property and reports success + // is indistinguishable from one that had nothing to drop, which is how a + // whole set of ComplexType properties went missing unnoticed until a page + // referencing them failed to build (mendixlabs/mxcli#1118). + var dropped []string for _, schema := range doc.Schemas { for _, et := range schema.EntityTypes { @@ -557,6 +577,18 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) // Resolve the merged property and key set by walking the BaseType chain. mergedProps, keyProps := mergedPropertiesWithKey(et, typeByQualified) + // Expand complex-typed properties into one attribute per leaf, the + // way Studio Pro imports them (MaxQty -> MaxQty_UoMNId, + // MaxQty_QuantityValue). Without this every such property fell + // through the `!strings.HasPrefix(p.Type, "Edm.")` drop below and + // vanished without a word, and the loss surfaced much later as + // CE1613 on a page written against the attributes Studio Pro would + // have made (mendixlabs/mxcli#1118). + flatProps, nestedComplex := doc.FlattenProperties(mergedProps) + for _, u := range nestedComplex { + dropped = append(dropped, fmt.Sprintf("%s.%s — complex type nested in a complex type; Mendix imports one level only", mendixName, u)) + } + keyPropSet := make(map[string]bool) for _, k := range keyProps { keyPropSet[k] = true @@ -626,31 +658,57 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) } } - // Build attributes from merged properties + // Build attributes from the flattened property set var attrs []*domainmodel.Attribute - for _, p := range mergedProps { + for _, p := range flatProps { // Drop collection-of-primitive — handled separately as primitive - // collection NPEs (not yet implemented). + // collection NPEs below. if strings.HasPrefix(p.Type, "Collection(") { continue } - // Drop non-Edm types (complex types and entity refs) — they need - // to be modelled as NPEs/associations, not implemented yet. + // Anything still not an Edm type after the flatten is an entity + // reference, an enum, or a complex type this document does not + // declare. None of them can become an attribute — but say so: + // silence here is what made mendixlabs/mxcli#1118 cost a build to discover. if !strings.HasPrefix(p.Type, "Edm.") { + dropped = append(dropped, fmt.Sprintf("%s.%s (%s) — not a supported attribute type", mendixName, p.Path(), p.Type)) continue } // Drop Edm.Duration — Mendix has no native duration type and // Studio Pro skips these properties. if p.Type == "Edm.Duration" { + dropped = append(dropped, fmt.Sprintf("%s.%s (Edm.Duration) — Mendix has no duration type; Studio Pro skips it too", mendixName, p.Path())) continue } + // Contract-side lookups key on the property PATH, which is what + // the service names in a PropertyPath annotation and what a + // $filter would address: "MaxQty/QuantityValue", not the local + // attribute name. + remoteName := p.Path() + creatable := defaultCreatable updatable := defaultUpdatable - if nonInsertable[p.Name] || p.Computed { + if nonInsertable[remoteName] || p.Computed { creatable = false } - if nonUpdatable[p.Name] || p.Computed || p.Immutable { + if nonUpdatable[remoteName] || p.Computed || p.Immutable { + updatable = false + } + // A property reached through a complex type is READ-ONLY whatever + // the entity set's Insert/Update restrictions say: "External + // entities that contain attributes of complex types can only be + // read or deleted. They cannot be created, updated, or used in + // external actions" (Consumed OData Service Requirements). + // + // Measured on 11.12.1 against a contract annotated + // Insertable=true AND Updatable=true: Mendix still reports the + // flattened attributes as Creatable=False / Updatable=False, so + // following the entity set instead is two CE6630 per attribute + // ("'MaxQty_UoMNId' is marked Creatable=False in the OData + // service, but True in the app"). + if p.RemotePath != "" { + creatable = false updatable = false } @@ -664,10 +722,10 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) attr := &domainmodel.Attribute{ Name: attrName, Type: edmToDomainModelAttrType(p, keyPropSet[p.Name]), - RemoteName: p.Name, + RemoteName: remoteName, RemoteType: p.Type, - Filterable: entitySet.AttrFilterable(p.Name), - Sortable: entitySet.AttrSortable(p.Name), + Filterable: entitySet.AttrFilterable(remoteName), + Sortable: entitySet.AttrSortable(remoteName), Creatable: creatable, Updatable: updatable, } @@ -744,6 +802,14 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) fmt.Fprintf(ctx.Output, "\nFrom %s into %s: %d created, %d updated, %d skipped, %d failed\n", svcQN, targetModule, created, updated, skipped, failed) + if len(dropped) > 0 { + sort.Strings(dropped) + fmt.Fprintf(ctx.Output, "\n %d contract propert(y/ies) could not be imported as attributes:\n", len(dropped)) + for _, d := range dropped { + fmt.Fprintf(ctx.Output, " %s\n", d) + } + } + if len(renamed) > 0 { sort.Strings(renamed) fmt.Fprintf(ctx.Output, "\n %d attribute name(s) changed — Mendix reserves the contract's spelling (CE7247),\n", len(renamed)) diff --git a/mdl/executor/cmd_contract_complextype_test.go b/mdl/executor/cmd_contract_complextype_test.go new file mode 100644 index 0000000000..c1e8da7cda --- /dev/null +++ b/mdl/executor/cmd_contract_complextype_test.go @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// mendixlabs/mxcli#1118: "mxcli silently discards any OData property whose type is a +// ComplexType defined in a different Schema namespace within the same $metadata +// document. The attributes are not created in the Mendix entity, and no warning +// or error is emitted." +// +// Studio Pro flattens them (`MaxQty.UoMNId` → `MaxQty_UoMNId`), which is what +// the reporter's pages and microflows were written against; without them the +// build fails with CE1613 naming attributes the import never made. +const complexTypeMetadata = ` + + + + + + + + + + + + + + + + + + + + + +` + +// importComplexTypeContract runs CREATE OR MODIFY EXTERNAL ENTITIES FROM over +// the metadata above and returns the entity that reached the backend plus the +// executor's output. +func importComplexTypeContract(t *testing.T, metadata string) (*domainmodel.Entity, string) { + t.Helper() + mod := mkModule("CustomModule") + svc := &model.ConsumedODataService{ + BaseElement: model.BaseElement{ID: nextID("cos")}, + ContainerID: mod.ID, + Name: "App", + MetadataUrl: "https://example.com/$metadata", + ODataVersion: "4.0", + Metadata: metadata, + } + h := mkHierarchy(mod) + withContainer(h, svc.ContainerID, mod.ID) + + dm := &domainmodel.DomainModel{} + dm.ID = nextID("dm") + + var created *domainmodel.Entity + 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 + }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + CreateEntityFunc: func(_ model.ID, e *domainmodel.Entity) error { + created = e + dm.Entities = append(dm.Entities, e) + return nil + }, + ProjectVersionFunc: func() *types.ProjectVersion { + return &types.ProjectVersion{MajorVersion: 11, MinorVersion: 12, ProductVersion: "11.12.0"} + }, + } + + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + stmt := &ast.CreateExternalEntitiesStmt{ + ServiceRef: ast.QualifiedName{Module: "CustomModule", Name: "App"}, + TargetModule: "CustomModule", + EntityNames: []string{"Definition"}, + CreateOrModify: true, + } + assertNoError(t, createExternalEntities(ctx, stmt)) + if created == nil { + t.Fatal("no entity was created") + } + return created, buf.String() +} + +func attrNames(e *domainmodel.Entity) []string { + var out []string + for _, a := range e.Attributes { + out = append(out, a.Name) + } + return out +} + +// The reported symptom: "The imported entity contains none of these attributes." +func TestCreateExternalEntities_FlattensCrossNamespaceComplexTypes(t *testing.T) { + ent, _ := importComplexTypeContract(t, complexTypeMetadata) + + want := []string{ + "MaxQty_UoMNId", "MaxQty_QuantityValue", + "MaxWeight_UoMNId", "MaxWeight_QuantityValue", + "MaxVolume_UoMNId", "MaxVolume_QuantityValue", + } + got := attrNames(ent) + for _, w := range want { + found := false + for _, g := range got { + if g == w { + found = true + } + } + if !found { + t.Errorf("attribute %q missing — the ComplexType property was silently dropped (mendixlabs/mxcli#1118). got %v", w, got) + } + } + + // The un-flattened complex property must not survive as an attribute of its + // own: Mendix has no complex-typed attribute, and a String(200) stand-in is + // the lossy simplification the report calls out. + for _, g := range got { + if g == "MaxQty" || g == "MaxWeight" || g == "MaxVolume" { + t.Errorf("complex property %q was kept as a scalar attribute", g) + } + } +} + +// The leaf's own type and facets decide the Mendix attribute, not the complex +// property's. A Decimal flattened as String is a silent data-type change that +// only shows up when an expression fails to compile. +func TestCreateExternalEntities_FlattenedAttributeKeepsLeafType(t *testing.T) { + ent, _ := importComplexTypeContract(t, complexTypeMetadata) + byName := map[string]*domainmodel.Attribute{} + for _, a := range ent.Attributes { + byName[a.Name] = a + } + + qty := byName["MaxQty_QuantityValue"] + if qty == nil { + t.Fatal("MaxQty_QuantityValue missing") + } + if qty.Type == nil || qty.Type.GetTypeName() != "Decimal" { + t.Errorf("MaxQty_QuantityValue type = %v, want Decimal", qty.Type) + } + if qty.RemoteType != "Edm.Decimal" { + t.Errorf("RemoteType = %q, want Edm.Decimal", qty.RemoteType) + } + + uom := byName["MaxQty_UoMNId"] + if uom == nil { + t.Fatal("MaxQty_UoMNId missing") + } + if uom.RemoteType != "Edm.String" { + t.Errorf("RemoteType = %q, want Edm.String", uom.RemoteType) + } +} + +// Whatever the import cannot map, it must SAY so. The bug is not only that the +// attributes were absent — it is that `exec` reported success and printed +// nothing, so the loss was discovered at build time as CE1613 on a page. +func TestCreateExternalEntities_ReportsPropertiesItCannotMap(t *testing.T) { + // A property typed as a complex type the document never declares cannot be + // flattened by anyone; it is the case that must still be reported. + const danglingComplex = ` + + + + + + + + + + + + + +` + + _, out := importComplexTypeContract(t, danglingComplex) + if !strings.Contains(out, "MaxQty") { + t.Errorf("the dropped property is not named in the output — this is the silence in mendixlabs/mxcli#1118:\n%s", out) + } +} + +// Mendix: "External entities that contain attributes of complex types can only +// be read or deleted. They cannot be created, updated, or used in external +// actions." So a flattened attribute is read-only even when the entity set's +// own contract says Insertable=true and Updatable=true. +// +// Measured on 11.12.1: following the entity set instead costs two CE6630 per +// flattened attribute — "'MaxQty_UoMNId' is marked Creatable=False in the OData +// service, but True in the app". +func TestCreateExternalEntities_FlattenedAttributesAreReadOnly(t *testing.T) { + const writableContract = ` + + + + + + + + + + + + + + + + + + + + + + + + + + +` + + ent, _ := importComplexTypeContract(t, writableContract) + byName := map[string]*domainmodel.Attribute{} + for _, a := range ent.Attributes { + byName[a.Name] = a + } + + flat := byName["MaxQty_UoMNId"] + if flat == nil { + t.Fatal("MaxQty_UoMNId missing") + } + if flat.Creatable || flat.Updatable { + t.Errorf("MaxQty_UoMNId Creatable=%v Updatable=%v, want both false — CE6630", + flat.Creatable, flat.Updatable) + } + + // The control: an ordinary property of the SAME writable entity set must + // still follow the contract. Without it this test passes against an import + // that marks everything read-only. + plain := byName["Label"] + if plain == nil { + t.Fatal("Label missing") + } + if !plain.Creatable || !plain.Updatable { + t.Errorf("Label Creatable=%v Updatable=%v, want both true — the contract says the set is writable", + plain.Creatable, plain.Updatable) + } +} + +// The remote name of a flattened attribute is the OData PATH — "MaxQty/UoMNId", +// not the underscore-joined local name. +// +// Measured on 11.12.1, three variants of the same project: +// +// RemoteName "MaxQty/UoMNId" -> The app contains: 0 errors. +// RemoteName "MaxQty_UoMNId" -> 4 x CE6615 "does not exist in the OData service" +// RemoteName "MaxQty_ZZNOTAPATH_UoM" -> 4 x CE6615 +// +// So mxbuild resolves the path into the complex type and genuinely checks it — +// the 0-error run is evidence, not a rubber stamp. Local name and remote name +// differ by separator here, which is the detail that looks like a typo in a diff. +func TestCreateExternalEntities_FlattenedRemoteNameIsTheODataPath(t *testing.T) { + ent, _ := importComplexTypeContract(t, complexTypeMetadata) + for _, a := range ent.Attributes { + if a.Name == "MaxQty_UoMNId" { + if a.RemoteName != "MaxQty/UoMNId" { + t.Fatalf("RemoteName = %q, want %q — CE6615 otherwise", a.RemoteName, "MaxQty/UoMNId") + } + return + } + } + t.Fatal("MaxQty_UoMNId missing") +} diff --git a/mdl/types/edmx.go b/mdl/types/edmx.go index e57f526e36..b20aeed31c 100644 --- a/mdl/types/edmx.go +++ b/mdl/types/edmx.go @@ -19,9 +19,24 @@ type EdmxDocument struct { // EdmSchema represents an EDM schema namespace. type EdmSchema struct { - Namespace string - EntityTypes []*EdmEntityType - EnumTypes []*EdmEnumType + Namespace string + EntityTypes []*EdmEntityType + ComplexTypes []*EdmComplexType + EnumTypes []*EdmEnumType +} + +// EdmComplexType represents a — a keyless structured value. +// +// The Mendix domain model has no complex types. Studio Pro imports the +// PROPERTIES of one as attributes of the containing entity, named +// `_`; see EdmxDocument.FlattenProperties. Parsing them +// is what makes that possible: with no ComplexType in the model, a property +// typed `Shared.Uom.Quantity` is indistinguishable from a property of a type +// nothing knows about, and every consumer drops it (mendixlabs/mxcli#1118). +type EdmComplexType struct { + Name string + BaseType string // Qualified name of the base complex type, empty if none + Properties []*EdmProperty } // EdmEntityType represents an entity type definition. @@ -45,6 +60,12 @@ type EdmProperty struct { MaxLength string // e.g. "200", "max" Scale string // e.g. "variable" + // RemotePath is how the SERVICE addresses this property when it was reached + // through a complex-typed property: "MaxQty/UoMNId". Empty on a property the + // entity type declares directly, where the path is just the name — use + // Path() rather than reading this field, so the two cases stay one lookup. + RemotePath string + // Capability annotations (OData Core V1). When true, the property is not // settable by the client: // Computed = server-computed, not settable on create or update. @@ -196,6 +217,15 @@ func ParseEdmx(metadataXML string) (*EdmxDocument, error) { schema.EntityTypes = append(schema.EntityTypes, entityType) } + // Parse complex types + for _, ct := range s.ComplexTypes { + complexType := &EdmComplexType{Name: ct.Name, BaseType: ct.BaseType} + for i := range ct.Properties { + complexType.Properties = append(complexType.Properties, parseXmlProperty(&ct.Properties[i])) + } + schema.ComplexTypes = append(schema.ComplexTypes, complexType) + } + // Parse enum types for _, en := range s.EnumTypes { enumType := &EdmEnumType{Name: en.Name} @@ -341,31 +371,8 @@ func parseXmlEntityType(et *xmlEntityType) *EdmEntityType { } // Parse properties - for _, p := range et.Properties { - prop := &EdmProperty{ - Name: p.Name, - Type: p.Type, - MaxLength: p.MaxLength, - Scale: p.Scale, - } - if p.Nullable != "" { - v := p.Nullable != "false" - prop.Nullable = &v - } - // ConcurrencyMode="Fixed" (OData v3) marks a property as an optimistic - // concurrency token — the server manages it, the client cannot set it. - if p.ConcurrencyMode == "Fixed" { - prop.Computed = true - } - for _, ann := range p.Annotations { - switch ann.Term { - case "Org.OData.Core.V1.Computed": - prop.Computed = ann.Bool == "" || ann.Bool == "true" - case "Org.OData.Core.V1.Immutable": - prop.Immutable = ann.Bool == "" || ann.Bool == "true" - } - } - entityType.Properties = append(entityType.Properties, prop) + for i := range et.Properties { + entityType.Properties = append(entityType.Properties, parseXmlProperty(&et.Properties[i])) } // Parse navigation properties @@ -391,6 +398,144 @@ func parseXmlEntityType(et *xmlEntityType) *EdmEntityType { return entityType } +// parseXmlProperty converts one element. Shared by entity types and +// complex types: a complex type's properties are turned into entity attributes +// verbatim, facets and capability annotations included, so a second copy of this +// would drift the two apart (the duplicate-resolver failure class). +func parseXmlProperty(p *xmlProperty) *EdmProperty { + prop := &EdmProperty{ + Name: p.Name, + Type: p.Type, + MaxLength: p.MaxLength, + Scale: p.Scale, + } + if p.Nullable != "" { + v := p.Nullable != "false" + prop.Nullable = &v + } + // ConcurrencyMode="Fixed" (OData v3) marks a property as an optimistic + // concurrency token — the server manages it, the client cannot set it. + if p.ConcurrencyMode == "Fixed" { + prop.Computed = true + } + for _, ann := range p.Annotations { + switch ann.Term { + case "Org.OData.Core.V1.Computed": + prop.Computed = ann.Bool == "" || ann.Bool == "true" + case "Org.OData.Core.V1.Immutable": + prop.Immutable = ann.Bool == "" || ann.Bool == "true" + } + } + return prop +} + +// Path returns how the OData service addresses this property — the name for a +// property the entity type declares itself, "MaxQty/UoMNId" for one reached +// through a complex-typed property. It is what belongs in the Mendix attribute's +// RemoteName, and what a capability annotation's PropertyPath names. +func (p *EdmProperty) Path() string { + if p.RemotePath != "" { + return p.RemotePath + } + return p.Name +} + +// FindComplexType resolves a complex type by its QUALIFIED name. +// +// Qualified, not short: one $metadata document may declare `Quantity` in two +// namespaces, and a short-name lookup would hand the entity whichever schema +// happened to parse first — a wrong set of attributes rather than a missing one, +// which is strictly harder to notice. FindEntityType's short-name fallback is +// deliberately not copied here. +func (d *EdmxDocument) FindComplexType(qualifiedName string) *EdmComplexType { + idx := strings.LastIndex(qualifiedName, ".") + if idx < 0 { + return nil + } + namespace, name := qualifiedName[:idx], qualifiedName[idx+1:] + for _, s := range d.Schemas { + if s.Namespace != namespace { + continue + } + for _, ct := range s.ComplexTypes { + if ct.Name == name { + return ct + } + } + } + return nil +} + +// complexTypeProperties returns a complex type's own properties preceded by +// those it inherits, walking the BaseType chain. The depth guard is for a +// document whose base types form a cycle — malformed, but it arrives over the +// network and must not hang the import. +func (d *EdmxDocument) complexTypeProperties(ct *EdmComplexType) []*EdmProperty { + var props []*EdmProperty + seen := map[*EdmComplexType]bool{} + var walk func(*EdmComplexType) + walk = func(c *EdmComplexType) { + if c == nil || seen[c] { + return + } + seen[c] = true + if c.BaseType != "" { + walk(d.FindComplexType(c.BaseType)) + } + props = append(props, c.Properties...) + } + walk(ct) + return props +} + +// FlattenProperties expands every complex-typed property into one property per +// leaf, and passes everything else through untouched. +// +// This is what Studio Pro does on import, and the reason it has to exist here: +// "Complex types are not supported by the domain model. However, Studio Pro +// allows you to read external entities that contain attributes of a complex type +// by importing the properties of the complex type as attributes of the +// containing entity […] the attribute names consist of the name of the complex +// attribute and the name of the property that is part of the complex type, +// separated by an underscore" (Consumed OData Service Requirements). So +// `MaxQty` of type `Shared.Uom.Quantity` becomes `MaxQty_UoMNId` and +// `MaxQty_QuantityValue`, addressed over the paths `MaxQty/UoMNId` and +// `MaxQty/QuantityValue`. +// +// Flattening is ONE level deep, matching the same page's "only the properties of +// the types described in Supported Attribute Types are supported" — that list is +// the primitive Edm types, so a complex type nested in a complex type is not +// importable. It is returned in unsupported rather than dropped, because a +// caller that says nothing is the defect this whole function exists to fix +// (mendixlabs/mxcli#1118). A property whose type is not a complex type this document +// declares is passed through unchanged for the caller's own rules to judge. +func (d *EdmxDocument) FlattenProperties(props []*EdmProperty) (flat []*EdmProperty, unsupported []string) { + for _, p := range props { + ct := d.FindComplexType(p.Type) + if ct == nil { + flat = append(flat, p) + continue + } + for _, leaf := range d.complexTypeProperties(ct) { + path := p.Name + "/" + leaf.Name + if !strings.HasPrefix(leaf.Type, "Edm.") { + unsupported = append(unsupported, fmt.Sprintf("%s (%s)", path, leaf.Type)) + continue + } + expanded := *leaf + expanded.Name = p.Name + "_" + leaf.Name + expanded.RemotePath = path + // The containing property's own capability annotations apply to + // every leaf underneath it: a computed complex value has no + // individually settable parts. + expanded.Computed = expanded.Computed || p.Computed + expanded.Immutable = expanded.Immutable || p.Immutable + flat = append(flat, &expanded) + } + } + return flat, unsupported +} + // applyCapabilityAnnotations reads Org.OData.Capabilities.V1.{Insert,Update, // Delete}Restrictions annotations on an entity set and stores the relevant // flags on the EdmEntitySet. @@ -530,6 +675,7 @@ type xmlDataService struct { type xmlSchema struct { Namespace string `xml:"Namespace,attr"` EntityTypes []xmlEntityType `xml:"EntityType"` + ComplexTypes []xmlComplexType `xml:"ComplexType"` EnumTypes []xmlEnumType `xml:"EnumType"` EntityContainers []xmlEntityContainer `xml:"EntityContainer"` Actions []xmlAction `xml:"Action"` @@ -557,6 +703,12 @@ type xmlEntityType struct { Annotations []xmlAnnotation `xml:"Annotation"` } +type xmlComplexType struct { + Name string `xml:"Name,attr"` + BaseType string `xml:"BaseType,attr"` + Properties []xmlProperty `xml:"Property"` +} + type xmlKey struct { PropertyRefs []xmlPropertyRef `xml:"PropertyRef"` } diff --git a/mdl/types/edmx_complextype_test.go b/mdl/types/edmx_complextype_test.go new file mode 100644 index 0000000000..3e6f9484ca --- /dev/null +++ b/mdl/types/edmx_complextype_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "testing" + +// mendixlabs/mxcli#1118. A $metadata document that declares its complex types in a +// SEPARATE Schema from the entity types that use them — the shape the report +// carried, and the shape Mendix's own documentation uses for its example +// (`HomeAddress` of type `Lato.Address` on an entity in another namespace). +const crossNamespaceComplexTypeMetadata = ` + + + + + + + + + + + + + + + + + + + + + + +` + +// The parser did not read at all, so nothing downstream could +// distinguish "a complex type whose properties we should flatten" from "a type +// we know nothing about". +func TestParseEdmx_ParsesComplexTypes(t *testing.T) { + doc, err := ParseEdmx(crossNamespaceComplexTypeMetadata) + if err != nil { + t.Fatal(err) + } + var shared *EdmSchema + for _, s := range doc.Schemas { + if s.Namespace == "Shared.Uom" { + shared = s + } + } + if shared == nil { + t.Fatal("Shared.Uom schema missing") + } + if len(shared.ComplexTypes) != 1 { + t.Fatalf("Shared.Uom has %d complex types, want 1", len(shared.ComplexTypes)) + } + ct := shared.ComplexTypes[0] + if ct.Name != "Quantity" { + t.Errorf("complex type name = %q, want Quantity", ct.Name) + } + if len(ct.Properties) != 2 { + t.Fatalf("Quantity has %d properties, want 2", len(ct.Properties)) + } + if ct.Properties[0].Name != "UoMNId" || ct.Properties[0].Type != "Edm.String" { + t.Errorf("first property = %+v", ct.Properties[0]) + } + if ct.Properties[0].MaxLength != "40" { + t.Errorf("MaxLength = %q, want 40 — facets must survive the flatten", ct.Properties[0].MaxLength) + } +} + +// Resolution is by QUALIFIED name. Two namespaces in one document may each +// declare `Quantity`; a short-name lookup picks whichever was parsed first, so +// the entity would silently get the other schema's properties. +func TestFindComplexType_ResolvesAcrossNamespacesByQualifiedName(t *testing.T) { + doc, err := ParseEdmx(crossNamespaceComplexTypeMetadata) + if err != nil { + t.Fatal(err) + } + ct := doc.FindComplexType("Shared.Uom.Quantity") + if ct == nil { + t.Fatal("Shared.Uom.Quantity did not resolve — this is the silent drop in mendixlabs/mxcli#1118") + } + if len(ct.Properties) != 2 || ct.Properties[0].Name != "UoMNId" { + t.Errorf("resolved the wrong Quantity: %+v", ct.Properties) + } + if other := doc.FindComplexType("App.Model.Quantity"); other == nil || other.Properties[0].Name != "WrongOne" { + t.Errorf("App.Model.Quantity resolved to %+v, want the one declaring WrongOne", other) + } + if doc.FindComplexType("Nope.Missing") != nil { + t.Error("an unknown qualified name must resolve to nil, not to a near match") + } +} + +// The flatten is what Studio Pro does: one attribute per leaf, named +// `Outer_Inner`, reached over the OData path `Outer/Inner`. +func TestFlattenProperties_ExpandsComplexTypeProperties(t *testing.T) { + doc, err := ParseEdmx(crossNamespaceComplexTypeMetadata) + if err != nil { + t.Fatal(err) + } + et := doc.FindEntityType("App.Model.Definition") + if et == nil { + t.Fatal("Definition not found") + } + flat, unsupported := doc.FlattenProperties(et.Properties) + if len(unsupported) != 0 { + t.Errorf("unexpected unsupported properties: %v", unsupported) + } + + want := []struct{ name, path, typ string }{ + {"Id", "", "Edm.String"}, + {"MaxQty_UoMNId", "MaxQty/UoMNId", "Edm.String"}, + {"MaxQty_QuantityValue", "MaxQty/QuantityValue", "Edm.Decimal"}, + } + if len(flat) != len(want) { + t.Fatalf("got %d properties, want %d: %+v", len(flat), len(want), flat) + } + for i, w := range want { + if flat[i].Name != w.name { + t.Errorf("[%d] Name = %q, want %q", i, flat[i].Name, w.name) + } + if flat[i].RemotePath != w.path { + t.Errorf("[%d] RemotePath = %q, want %q", i, flat[i].RemotePath, w.path) + } + if flat[i].Type != w.typ { + t.Errorf("[%d] Type = %q, want %q", i, flat[i].Type, w.typ) + } + } + // The facet has to come from the leaf, not from the complex property. + if flat[1].MaxLength != "40" { + t.Errorf("MaxQty_UoMNId MaxLength = %q, want 40", flat[1].MaxLength) + } +} From ef99d39eba80e86193dda595a358b9360725337f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 18:45:19 +0000 Subject: [PATCH 2/7] fix: DESCRIBE ENUMERATION drops every caption on a non-en_US project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as mendixlabs/mxcli#1113: "Every value comes back with an empty caption: `MyValue ''`, regardless of what's configured in Studio Pro." A caption is a Texts$Text, and Mendix has no language-neutral text — every Texts$Translation carries a LanguageCode. The read asked for a hardcoded "en_US", so on a project whose DefaultLanguageCode is anything else the lookup missed and every caption rendered as ''. The write side already resolves the project's language (#970) and the widget read side was fixed for the same reason (#702); the enumeration read was the site neither sweep reached. Reproduced end to end on an nl_NL copy of testdata/expr-checker, with the captions plainly present in the stored unit (`Texts$Translation LanguageCode nl_NL Text Nieuw`): create or modify enumeration MyFirstModule.OrderStatus ( Fresh '', Shipped '', Cancelled '' ); That output is also destructive, which is the reported impact: fed back through exec it reported "Modified enumeration" and left the stored translations empty. After the fix the same round trip reports "Unchanged enumeration" and the unit is byte-identical. - describeEnumeration and the diff renderer read the project's language (describeDefaultLanguage), falling back to en_US and then to any stored translation, so a caption Studio Pro shows is never reported as empty. - pickTextTranslation's last-resort fallback now sorts by language code. Ranging the map returned a different language per run, which made DESCRIBE output undiffable on a multi-language project — a defect a single-language repro cannot show. - The catalog's ENUMERATION_VALUES.Caption column follows the same order, so the catalog and DESCRIBE no longer disagree about the same value. CATALOG.ENUMERATIONS is unchanged: it is the enumeration-level table and never had a caption column — the captions are in CATALOG.ENUMERATION_VALUES. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017m5tisvk1c9D3gZGsNws5A --- .../fix-issue/findings/mdl-executor.jsonl | 1 + CLAUDE.md | 2 +- .../enumeration-1113-caption-language.mdl | 50 ++++++ mdl/catalog/builder.go | 6 + mdl/catalog/builder_modules.go | 26 ++-- mdl/catalog/language.go | 55 +++++++ mdl/catalog/language_test.go | 72 +++++++++ mdl/executor/cmd_diff_mdl.go | 10 +- mdl/executor/cmd_enumerations.go | 13 +- .../cmd_enumerations_caption_language_test.go | 144 ++++++++++++++++++ mdl/executor/describe_language.go | 24 ++- 11 files changed, 372 insertions(+), 31 deletions(-) create mode 100644 mdl-examples/bug-tests/enumeration-1113-caption-language.mdl create mode 100644 mdl/catalog/language.go create mode 100644 mdl/catalog/language_test.go create mode 100644 mdl/executor/cmd_enumerations_caption_language_test.go diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index fdf81448e0..8329017639 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -637,3 +637,4 @@ {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A .def.json that maps a Data Grid 2 column filter's `linkedDs` produces a widget mxbuild rejects with CE0642 \"Property 'Datasource to Filter' is required\" — naming the very property the value was written into", "cause": "`linkedDs` is declared `isLinked=\"true\"` in widget.xml: the platform fills it from the containing DataGrid2, and mxbuild resolves it from the parent rather than reading what is stored. mxcli could not tell a linked datasource from an authorable one because IsLinked, though present in the template ValueType, was not carried into PropertyTypeIDEntry", "file": "`mdl/types/widget_property_type.go` (IsLinked), `modelsdk/widgets/loader.go`, `mdl/executor/widget_engine.go` (`refuseLinkedDataSourceMapping`)", "insight": "Before mapping a widget property, check `isLinked` in widget.xml — a linked property is the platform's to fill, the ADR-0005 'author only what the model owns' rule wearing a widget hat. Three cheap measurements settle it faster than reasoning: grep the shipped template's ValueType for IsLinked, dump the property off Studio Pro-authored widgets in testdata/expr-checker (5 of 5 store linkedDs empty), and mx check the correct shape (0 errors WITHOUT it). Beware the inverted signal: writing the value does NOT clear CE0642, so a failing check after writing it looks like the value is missing rather than unwanted. Across all widget packages in testdata, linkedDs is the ONLY linked datasource among the 8 multi-datasource widgets — DROPDOWNFILTER is single-source from MDL's side, ComboBox and the 6 charts are genuinely multi-source", "ce": ["CE0642"]} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A chart series given BOTH a static and a dynamic datasource writes its static x/y attributes against the DYNAMIC source's entity — mxbuild reports CE1613 \"The selected attribute 'CH.Forecast.Region' no longer exists.\"", "cause": "buildObjectListItem pre-resolves every datasource the item configures and dropped each resolved entity into the one shared pageBuilder.entityContext, so the LAST one won. The per-property link was already in hand and ignored: ItemPropertyMapping.DataSource carries widget.xml's `dataSource=\"...\"` and GenerateDefJSON already emits it for every chart dependent", "file": "`mdl/executor/widget_engine.go` (`itemEntityContextFor`, `prebuiltEntities` in `buildObjectListItem`)", "insight": "The item twin of the widget-level per-datasource context (#1109). Look for the SECOND copy whenever a context fix lands at widget level — object-list items run the same pre-resolve/resolve shape with their own loop. The shipped chart defs already map staticDataSource AND dynamicDataSource with every dependent's link, so nothing needed mapping; the links simply were not read. Note the weak in-repo signals: `mxcli check` only warns (MDL-WIDGET10, the inactive set is hidden) and the describe output looks right, so the defect is visible only in the stored BSON or from mxbuild. Charts' static/dynamic sit INSIDE the `lines` object list, not at widget level — a recursive widget.xml scan makes them look like widget properties", "ce": ["CE1613"]} {"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY MICROFLOW` re-enables concurrent execution on a microflow that disallowed it \u2014 the running app's concurrency protection removed \u2014 and drops the concurrency error message (all translations) and error microflow, plus `MarkAsUsed`. Every checker is green: **CE4899 fires only on disallow-without-a-message, never on allow**, so the one error that exists in this area is exactly the one the reset switches off", "cause": "`buildMicroflowFromStmt` built the rebuild struct with `AllowConcurrentExecution: true` and `MarkAsUsed: false` literals, and `microflowToGen` wrote `SetConcurrencyErrorMicroflowQualifiedName(\"\")` + a bare `genTexts.NewText()`. The backend already READ the two flags back (the #723 \u00a7A fix), so the round-trip test passed while the bug was live \u2014 the executor overwrote them before the backend ever saw them", "file": "`mdl/executor/cmd_microflows_build.go` (buildMicroflowFromStmt), `mdl/backend/modelsdk/microflow_write.go` (microflowToGen), `mdl/backend/modelsdk/microflow.go` (microflowFromGen), `sdk/microflows/microflows.go`", "fix": "Carry all four from the stored microflow, seeding the locals with the NEW-microflow defaults (true/false) so no separate preserve flag is needed. The error message reuses the existing `textFromGen`/`textToGen` pair, so translations survive; nil still emits the bare empty `Texts$Text` the writer always wrote", "insight": "**A passing round-trip test at one layer says nothing about the layer above it.** `TestMicroflowRoundTrip_ConcurrentExecutionFlags` had guarded these two flags since #723 and was green throughout, because the executor's rebuild struct overwrites them before calling the backend. When a property is reset, locate the LAST writer on the path, not the first one that looks responsible. **And check which way a reset goes**: #723's backend bug wrote the Go zero value (allow -> disallow) and hit CE4899 immediately; the executor's literal writes the opposite (disallow -> allow), and the same CE4899 that caught the first direction is structurally blind to the second. A checker that catches a property's loss in one direction is not coverage for that property. Two methodological traps in the test itself, both hit: `bytes.Equal` on two encodes of the same microflow ALWAYS differs (fresh random sub-element `$ID`s \u2014 the reason `canon` exists), and `canon.Equal` on a whole microflow always differs too, because `StableId` is a fresh GUID *value* per encode and `Equal` does not mask \u2014 only `Reconcile` may be asked that question. Compare the sub-element under test, or use Reconcile. Controls: hardcoding the executor literals back, emptying the writer's pair, and stubbing the reader each fail a different test with the reported symptom"} +{"area":"mdl/executor","date":"2026-09-17","symptom":"`DESCRIBE ENUMERATION Mod.E` prints every value with an empty caption (`MyValue ''`) although Studio Pro shows them. Re-executing that output then DESTROYS the real captions (exec reports \"Modified enumeration\" and the stored Texts$Translation goes empty). Reported on Windows, single-language project, v0.18.0 and v0.22.0","cause":"The read asked `v.Caption.GetTranslation(\"en_US\")`. Mendix has no language-neutral text: a project whose DefaultLanguageCode is nl_NL stores the caption under nl_NL and nothing else, so the lookup misses and returns \"\". #970 fixed the WRITE side to use the project language and #702 fixed the widget READ side; the enumeration/validation-rule/message-template reads were the sites neither sweep reached","file":"`mdl/executor/cmd_enumerations.go` (describeEnumeration), `cmd_diff_mdl.go` (enumerationToMDL), `describe_language.go` (pickTextTranslation's fallback now sorts), `mdl/catalog/language.go` + `builder_modules.go`","insight":"**Reproduce it with mxcli alone — no Studio Pro and no non-English project needed.** `ALTER SETTINGS LANGUAGE ADD OR MODIFY 'nl_NL' (...); ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL';` on a copy of any fixture, then CREATE the enumeration: the write side already honours the project language, so the captions land under nl_NL and DESCRIBE reads '' immediately. That also gives the impact control for free — feed the '' output back through exec and grep the .mxunit for `Texts$Translation LanguageCode nl_NL Text ` with nothing after it. **The plausible wrong turn to skip**: suspecting the codec or a gen storage-name mismatch. `EnumerationValue.Caption` is NOT in keyaudit_test.go and the strings are plainly visible in the unit — dump the .mxunit with a printable-ASCII regex FIRST (one command) and the language code tells you it is a read-side language bug, not a decode bug. **The fallback has to sort**: `for _, v := range t.Translations` returns a different language per run, so a multi-language project's DESCRIBE output was undiffable — a bug that a single-language repro can never show.","refs":["mendixlabs/mxcli#1113","mendixlabs/mxcli#970","mendixlabs/mxcli#702"],"ce":[]} diff --git a/CLAUDE.md b/CLAUDE.md index 0de38ed30c..66abbfdb44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,7 +645,7 @@ New MDL commands or language features must be wired through the full pipeline: - [ ] New packages have test files - [ ] New executor commands have MDL examples in `mdl-examples/doctype-tests/` - [ ] **MDL syntax changes** — any PR that adds or modifies MDL syntax must include working examples in `mdl-examples/doctype-tests/` -- [ ] **Bug fixes** — every bug fix should include an MDL test script in `mdl-examples/bug-tests/` that reproduces the issue, so the fix can be verified in Studio Pro if applicable. **Two numbering namespaces meet in that directory**: the historical files are named after `mendixlabs/mxcli` **PR** numbers (`261-mx9-microflow-roundtrip.mdl` is upstream PR #261), while issues filed on the fork are `ako/mxcli` numbers — and the two sequences already collide on 261–266. Name a file after a fork issue with a topic prefix (`mapping-261-object-handling-backup.mdl`) and write the reference qualified (`ako/mxcli#261`) wherever it appears, or the number silently resolves to the wrong thing +- [ ] **Bug fixes** — every bug fix should include an MDL test script in `mdl-examples/bug-tests/` that reproduces the issue, so the fix can be verified in Studio Pro if applicable. **Three numbering namespaces meet in that directory**: the historical files are named after `mendixlabs/mxcli` **PR** numbers (`261-mx9-microflow-roundtrip.mdl` is upstream PR #261), issues filed on the fork are `ako/mxcli` numbers — and the two sequences already collide on 261–266 — while a few names are a **Mendix version** with the dot dropped (`1113-database-query-type-enum.mdl` is Mendix 11.13, not issue 1113). Name a file after a fork issue with a topic prefix (`mapping-261-object-handling-backup.mdl`) and write the reference qualified (`ako/mxcli#261`) wherever it appears, or the number silently resolves to the wrong thing - [ ] Integration paths (not just helpers) are tested - [ ] Tests don't rely on `time.Sleep` for synchronization — use channels or polling with timeout diff --git a/mdl-examples/bug-tests/enumeration-1113-caption-language.mdl b/mdl-examples/bug-tests/enumeration-1113-caption-language.mdl new file mode 100644 index 0000000000..3f5f912e51 --- /dev/null +++ b/mdl-examples/bug-tests/enumeration-1113-caption-language.mdl @@ -0,0 +1,50 @@ +-- ============================================================================ +-- Bug test: mendixlabs/mxcli#1113 — DESCRIBE ENUMERATION returned empty captions +-- ============================================================================ +-- +-- Named with a topic prefix: "1113" is already taken in this directory by +-- 1113-database-query-type-enum.mdl, where it is the MENDIX VERSION 11.13. That +-- is a third numbering namespace meeting here, beside upstream PR numbers and +-- ako/mxcli issue numbers. +-- +-- Reported symptom: "Every value comes back with an empty caption: MyValue '', +-- regardless of what's configured in Studio Pro." +-- +-- Cause: a caption is a Texts$Text and Mendix has no language-neutral text — +-- every Texts$Translation carries a LanguageCode. The read side asked for a +-- hardcoded "en_US", so on a project whose default language is anything else +-- (the reporter's was single-language, not en_US) every caption read as empty. +-- DESCRIBE now reads the language CREATE writes (the project's +-- DefaultLanguageCode, #970), so describe -> exec round-trips. +-- +-- To verify: +-- 1. Run this script against a project. +-- 2. mxcli -p app.mpr -c "DESCRIBE ENUMERATION Bug1113.OrderStatus" +-- → each value prints its caption, NOT ''. +-- 3. Feed that output back through `mxcli exec`: it must report +-- "Unchanged enumeration", not "Modified enumeration". Before the fix it +-- wrote the empty strings back and destroyed the captions. +-- +-- The bug only shows on a non-en_US project. To reproduce the original +-- symptom, first switch the project's default language: +-- ALTER SETTINGS LANGUAGE ADD OR MODIFY 'nl_NL' (CheckCompleteness: false); +-- ALTER SETTINGS LANGUAGE DefaultLanguageCode = 'nl_NL'; +-- then re-run the CREATE below so the captions are stored under nl_NL. +-- (Not done here: this script must leave the project's language alone.) +-- ============================================================================ + +CREATE MODULE Bug1113; + +CREATE ENUMERATION Bug1113.OrderStatus ( + Fresh 'Nieuw', + Shipped 'Verzonden', + Cancelled 'Geannuleerd' +); + +-- ALTER must re-caption in place, in the same language, so the value stays +-- readable by DESCRIBE afterwards. +ALTER ENUMERATION Bug1113.OrderStatus MODIFY VALUE Shipped CAPTION 'Onderweg'; + +ALTER ENUMERATION Bug1113.OrderStatus ADD VALUE IF NOT EXISTS Returned CAPTION 'Geretourneerd'; + +DESCRIBE ENUMERATION Bug1113.OrderStatus; diff --git a/mdl/catalog/builder.go b/mdl/catalog/builder.go index c5722d57d0..b43601664e 100644 --- a/mdl/catalog/builder.go +++ b/mdl/catalog/builder.go @@ -93,6 +93,12 @@ type Builder struct { resolution float64 // Leiden resolution for the graph-analysis pass describeFunc DescribeFunc + // The project's DefaultLanguageCode, resolved once per build by + // defaultLanguage(). Texts (enumeration value captions today) are read in + // this language: see language.go. + defaultLang string + defaultLangLoaded bool + // Scheduled event → microflow edges, collected while cataloguing the events // and emitted by buildReferences (a later pass). Carried on the Builder // rather than re-queried because CatalogTx has no Query. diff --git a/mdl/catalog/builder_modules.go b/mdl/catalog/builder_modules.go index 6f5d88f7a7..779614762a 100644 --- a/mdl/catalog/builder_modules.go +++ b/mdl/catalog/builder_modules.go @@ -4,7 +4,6 @@ package catalog import ( "fmt" - "sort" "strings" "github.com/mendixlabs/mxcli/sdk/domainmodel" @@ -280,6 +279,7 @@ func (b *Builder) buildEnumerations() error { projectID, snapshotID := b.snapshotMeta() valueCount := 0 + lang := b.defaultLanguage() for _, enum := range enums { // Get module name using hierarchy @@ -316,24 +316,16 @@ func (b *Builder) buildEnumerations() error { if id == "" { id = string(enum.ID) + "/" + v.Name } + // The project's own language first: a hardcoded "en_US" put the + // English caption in the column of a Dutch project that has both, so + // CATALOG.ENUMERATION_VALUES and DESCRIBE disagreed about the same + // value (mendixlabs/mxcli#1113). Any translation still beats none — + // nothing downstream keys off this, the checker matches on Name — + // and the last resort is sorted, since a row that varies run to run + // is indistinguishable from a model change. caption := "" if v.Caption != nil { - // Any translation is better than none for a display caption, and - // nothing downstream keys off it — the checker matches on Name. - caption = v.Caption.GetTranslation("en_US") - if caption == "" { - // Fall back to some other language rather than storing - // nothing, but pick it deterministically: iterating the map - // directly would make the catalog row vary run to run. - langs := make([]string, 0, len(v.Caption.Translations)) - for lang := range v.Caption.Translations { - langs = append(langs, lang) - } - sort.Strings(langs) - if len(langs) > 0 { - caption = v.Caption.Translations[langs[0]] - } - } + caption = pickTranslation(v.Caption.Translations, lang) } if _, err := valueStmt.Exec( id, diff --git a/mdl/catalog/language.go b/mdl/catalog/language.go new file mode 100644 index 0000000000..b9996a7d79 --- /dev/null +++ b/mdl/catalog/language.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import "sort" + +// defaultLanguage returns the project's DefaultLanguageCode, resolved once per +// build. Mendix has no language-neutral text: every Texts$Translation carries a +// LanguageCode, so a caption stored by a Dutch project exists only under +// "nl_NL". A catalog column filled by asking for "en_US" is therefore empty — +// or, worse, filled from whichever other language sorts first — on every +// project whose default is not en_US (mendixlabs/mxcli#1113). +func (b *Builder) defaultLanguage() string { + if b.defaultLangLoaded { + return b.defaultLang + } + b.defaultLang = catalogFallbackLanguage + if ps, err := b.reader.GetProjectSettings(); err == nil && + ps != nil && ps.Language != nil && ps.Language.DefaultLanguageCode != "" { + b.defaultLang = ps.Language.DefaultLanguageCode + } + b.defaultLangLoaded = true + return b.defaultLang +} + +const catalogFallbackLanguage = "en_US" + +// pickTranslation mirrors the executor's pickTextTranslation: the preferred +// language, else en_US, else the non-empty translation with the lowest language +// code. The last step is sorted rather than a bare map range because a catalog +// row that varies between refreshes is indistinguishable from a model change. +func pickTranslation(translations map[string]string, preferredLang string) string { + if len(translations) == 0 { + return "" + } + if preferredLang != "" { + if v := translations[preferredLang]; v != "" { + return v + } + } + if v := translations[catalogFallbackLanguage]; v != "" { + return v + } + langs := make([]string, 0, len(translations)) + for lang := range translations { + langs = append(langs, lang) + } + sort.Strings(langs) + for _, lang := range langs { + if v := translations[lang]; v != "" { + return v + } + } + return "" +} diff --git a/mdl/catalog/language_test.go b/mdl/catalog/language_test.go new file mode 100644 index 0000000000..59f8a95c78 --- /dev/null +++ b/mdl/catalog/language_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package catalog + +import ( + "testing" + + "github.com/mendixlabs/mxcli/model" +) + +// languageOnlyReader answers GetProjectSettings and nothing else. The embedded +// interface is nil on purpose: any other call panics, which keeps the test +// honest about what defaultLanguage() is allowed to touch. +type languageOnlyReader struct { + CatalogReader + settings *model.ProjectSettings + err error +} + +func (r languageOnlyReader) GetProjectSettings() (*model.ProjectSettings, error) { + return r.settings, r.err +} + +func TestBuilderDefaultLanguage_Issue1113(t *testing.T) { + nl := &model.ProjectSettings{Language: &model.LanguageSettings{DefaultLanguageCode: "nl_NL"}} + tests := []struct { + name string + reader languageOnlyReader + want string + }{ + {"project default is used", languageOnlyReader{settings: nl}, "nl_NL"}, + {"no settings falls back", languageOnlyReader{}, "en_US"}, + {"no language block falls back", languageOnlyReader{settings: &model.ProjectSettings{}}, "en_US"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + b := &Builder{reader: tc.reader} + if got := b.defaultLanguage(); got != tc.want { + t.Errorf("defaultLanguage = %q, want %q", got, tc.want) + } + }) + } +} + +// The catalog's caption column is filled from the same translations DESCRIBE +// reads, so the two must agree on which language wins. Before #1113 the column +// preferred en_US unconditionally, which disagreed with DESCRIBE on any +// multi-language project. +func TestPickTranslation_Issue1113(t *testing.T) { + tests := []struct { + name string + translations map[string]string + preferred string + want string + }{ + {"only the project language is stored", map[string]string{"nl_NL": "Nieuw"}, "nl_NL", "Nieuw"}, + {"project language wins over en_US", map[string]string{"en_US": "New", "nl_NL": "Nieuw"}, "nl_NL", "Nieuw"}, + {"en_US is the first fallback", map[string]string{"en_US": "New", "de_DE": "Neu"}, "nl_NL", "New"}, + {"lowest language code is the last resort", map[string]string{"nl_NL": "Nieuw", "de_DE": "Neu"}, "pt_BR", "Neu"}, + {"empty translation is skipped", map[string]string{"de_DE": "", "nl_NL": "Nieuw"}, "pt_BR", "Nieuw"}, + {"no translations", nil, "nl_NL", ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + for i := 0; i < 20; i++ { + if got := pickTranslation(tc.translations, tc.preferred); got != tc.want { + t.Fatalf("pickTranslation = %q, want %q", got, tc.want) + } + } + }) + } +} diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index c95acfc7b6..bad2367c5e 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -363,15 +363,17 @@ func enumerationToMDL(ctx *ExecContext, moduleName string, enum *model.Enumerati lines = append(lines, fmt.Sprintf("create enumeration %s.%s (", moduleName, enum.Name)) + // Same read as DESCRIBE: a hardcoded "en_US" renders every caption of a + // non-en_US project as '' and makes the diff claim the script changes them + // (mendixlabs/mxcli#1113). + lang := describeDefaultLanguage(ctx) + for i, v := range enum.Values { comma := "," if i == len(enum.Values)-1 { comma = "" } - caption := "" - if v.Caption != nil { - caption = v.Caption.GetTranslation("en_US") - } + caption := pickTextTranslation(v.Caption, lang) lines = append(lines, fmt.Sprintf(" %s '%s'%s", v.Name, caption, comma)) } diff --git a/mdl/executor/cmd_enumerations.go b/mdl/executor/cmd_enumerations.go index 0542ea51b0..5e0aa8ec98 100644 --- a/mdl/executor/cmd_enumerations.go +++ b/mdl/executor/cmd_enumerations.go @@ -429,16 +429,21 @@ func describeEnumeration(ctx *ExecContext, name ast.QualifiedName) error { return nil } + // A caption is a Texts$Text, and Mendix has no language-neutral text: + // on a project whose default language is not en_US the only stored + // translation carries that language code, so asking for "en_US" + // reported every caption as empty (mendixlabs/mxcli#1113). Read the + // language CREATE writes (authoringLanguage), or describe -> exec + // round-trips a real caption into ''. + lang := describeDefaultLanguage(ctx) + fmt.Fprintf(ctx.Output, "create or modify enumeration %s.%s (\n", modName, enum.Name) for i, v := range enum.Values { comma := "," if i == len(enum.Values)-1 { comma = "" } - caption := "" - if v.Caption != nil { - caption = v.Caption.GetTranslation("en_US") - } + caption := pickTextTranslation(v.Caption, lang) fmt.Fprintf(ctx.Output, " %s '%s'%s\n", v.Name, caption, comma) } // Emit the module folder so a moved enumeration round-trips (Bug 12b). diff --git a/mdl/executor/cmd_enumerations_caption_language_test.go b/mdl/executor/cmd_enumerations_caption_language_test.go new file mode 100644 index 0000000000..b674f083ef --- /dev/null +++ b/mdl/executor/cmd_enumerations_caption_language_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +// mendixlabs/mxcli#1113: "DESCRIBE ENUMERATION returns empty captions for values +// that have captions defined in Studio Pro" — every value came back as +// `MyValue ”`, whatever Studio Pro showed. +// +// The read side asked the stored Texts$Text for a hardcoded "en_US", so a +// project whose default language is anything else lost every caption. Mendix has +// no language-neutral text (#970): a Dutch project's captions carry +// LanguageCode "nl_NL" and nothing else. Measured end to end before the fix, on +// an nl_NL copy of testdata/expr-checker, with the captions plainly present in +// the stored unit (`Texts$Translation LanguageCode nl_NL Text Nieuw`): +// +// create or modify enumeration MyFirstModule.OrderStatus ( +// Fresh '', +// Shipped '', +// Cancelled '' +// ); +// +// which is the reported symptom, and is also destructive: re-executing that +// DESCRIBE output replaces the real captions with empty strings. +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" +) + +// enumWithCaptions builds a one-value enumeration whose caption carries the +// given translations. +func enumWithCaptions(modID model.ID, translations map[string]string) *model.Enumeration { + return &model.Enumeration{ + BaseElement: model.BaseElement{ID: nextID("enum")}, + ContainerID: modID, + Name: "OrderStatus", + Values: []model.EnumerationValue{{ + BaseElement: model.BaseElement{ID: nextID("ev")}, + Name: "Fresh", + Caption: &model.Text{Translations: translations}, + }}, + } +} + +// describeEnumerationWithLanguage runs DESCRIBE ENUMERATION against a project +// whose DefaultLanguageCode is defaultLang and returns the output. +func describeEnumerationWithLanguage(t *testing.T, defaultLang string, translations map[string]string) string { + t.Helper() + mod := mkModule("MyFirstModule") + enum := enumWithCaptions(mod.ID, translations) + + h := mkHierarchy(mod) + withContainer(h, enum.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListEnumerationsFunc: func() ([]*model.Enumeration, error) { return []*model.Enumeration{enum}, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + if defaultLang == "" { + return nil, nil + } + return &model.ProjectSettings{ + Language: &model.LanguageSettings{DefaultLanguageCode: defaultLang}, + }, nil + }, + } + + ctx, buf := newMockCtx(t, withBackend(mb), withHierarchy(h)) + assertNoError(t, describeEnumeration(ctx, ast.QualifiedName{Module: "MyFirstModule", Name: "OrderStatus"})) + return buf.String() +} + +func TestDescribeEnumeration_CaptionFollowsProjectLanguage_Issue1113(t *testing.T) { + tests := []struct { + name string + defaultLang string + translations map[string]string + want string + }{{ + // The reported case: a single-language project that is not en_US. + name: "nl_NL-only project", + defaultLang: "nl_NL", + translations: map[string]string{"nl_NL": "Nieuw"}, + want: "Nieuw", + }, { + // DESCRIBE must read back the language CREATE writes, or describe -> exec + // stops round-tripping: it would rewrite the Dutch caption as English. + name: "multi-language project prefers its default", + defaultLang: "nl_NL", + translations: map[string]string{"en_US": "New", "nl_NL": "Nieuw"}, + want: "Nieuw", + }, { + name: "en_US project is unaffected", + defaultLang: "en_US", + translations: map[string]string{"en_US": "New"}, + want: "New", + }, { + // A caption Studio Pro shows is never reported as empty: with no + // translation in either the project language or en_US, any stored one + // beats nothing. + name: "falls back to a stored translation", + defaultLang: "nl_NL", + translations: map[string]string{"de_DE": "Neu"}, + want: "Neu", + }, { + name: "settings unavailable falls back to en_US", + defaultLang: "", + translations: map[string]string{"en_US": "New"}, + want: "New", + }} + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := describeEnumerationWithLanguage(t, tc.defaultLang, tc.translations) + if strings.Contains(out, "Fresh ''") { + t.Errorf("caption came back empty (issue #1113):\n%s", out) + } + if !strings.Contains(out, "Fresh '"+tc.want+"'") { + t.Errorf("want caption %q, got:\n%s", tc.want, out) + } + }) + } +} + +// The last-resort fallback iterates a map, so it has to sort: a caption that +// changes between runs makes DESCRIBE output undiffable and the round-trip +// non-deterministic. +func TestPickTextTranslation_FallbackIsDeterministic(t *testing.T) { + txt := &model.Text{Translations: map[string]string{ + "nl_NL": "Nieuw", "de_DE": "Neu", "fr_FR": "Nouveau", "es_ES": "Nuevo", + }} + first := pickTextTranslation(txt, "pt_BR") + for i := 0; i < 50; i++ { + if got := pickTextTranslation(txt, "pt_BR"); got != first { + t.Fatalf("fallback is not deterministic: %q then %q", first, got) + } + } + if first != "Neu" { + t.Errorf("fallback = %q, want the lowest language code's text %q", first, "Neu") + } +} diff --git a/mdl/executor/describe_language.go b/mdl/executor/describe_language.go index 22be7e1e0b..699737fa6a 100644 --- a/mdl/executor/describe_language.go +++ b/mdl/executor/describe_language.go @@ -8,7 +8,11 @@ // e.g. the Dutch default "Tekst"). package executor -import "github.com/mendixlabs/mxcli/model" +import ( + "sort" + + "github.com/mendixlabs/mxcli/model" +) const fallbackLanguageCode = "en_US" @@ -72,8 +76,9 @@ func selectTranslationText(items []any, preferredLang string) string { } // pickTextTranslation selects the best translation from a model.Text map: the -// preferred language, else en_US, else the first non-empty. Mirrors -// selectTranslationText for the model-level texts (e.g. page Title). +// preferred language, else en_US, else the non-empty one with the lowest +// language code. Mirrors selectTranslationText for the model-level texts (e.g. +// page Title, enumeration value Caption). func pickTextTranslation(t *model.Text, preferredLang string) string { if t == nil || len(t.Translations) == 0 { return "" @@ -86,8 +91,17 @@ func pickTextTranslation(t *model.Text, preferredLang string) string { if v := t.Translations[fallbackLanguageCode]; v != "" { return v } - for _, v := range t.Translations { - if v != "" { + // Last resort: any stored translation beats reporting a caption Studio Pro + // plainly shows as empty. Pick it by sorted language code — iterating the map + // directly makes DESCRIBE output vary run to run, which breaks diffing and + // makes the describe -> exec round trip non-deterministic. + langs := make([]string, 0, len(t.Translations)) + for lang := range t.Translations { + langs = append(langs, lang) + } + sort.Strings(langs) + for _, lang := range langs { + if v := t.Translations[lang]; v != "" { return v } } From b6b6560b5b9063e55a88b143f73be2a88021c5fd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 18:52:11 +0000 Subject: [PATCH 3/7] refactor: read every executor DESCRIBE text in the project's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enumeration caption fixed in the previous commit was one call site of a pattern the grep finds everywhere: `GetTranslation("en_US")`, or an en_US lookup followed by `for _, t := range …Translations { break }`. Applied consistently, per the review checklist, so the next report is not the same bug wearing a different doctype. There are now no hardcoded en_US reads left in mdl/executor. Two distinct defects were behind the one pattern: - Sites with no fallback dropped the text entirely. Measured on the same nl_NL project, pre-sweep binary vs post, `describe entity` silently loses the validation feedback: Referentie: String(50) not null against Referentie: String(50) not null error 'Referentie is verplicht' - Sites with a bare map-range fallback returned a different language per run on a multi-language project. They were never empty, so the damage is non-deterministic DESCRIBE output rather than loss — invisible to a single-language repro, and it makes describe output undiffable. All of them now go through pickTextTranslation: project language, then en_US, then the non-empty translation with the lowest language code. An en_US project is unaffected at every site. Mermaid/ELK diagram details take the language as a parameter — two hops from renderFlowMermaid and three from emitMicroflowELK — rather than reading a package global. Control: reverting the describe-entity site alone (with `_ = lang` to keep it compiling) makes TestDescribeEntity_ValidationMessageFollows ProjectLanguage_Issue1113 fail with the feedback message absent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017m5tisvk1c9D3gZGsNws5A --- mdl/executor/cmd_diff_mdl.go | 15 ++---- mdl/executor/cmd_entities_describe.go | 16 +++---- .../cmd_enumerations_caption_language_test.go | 47 +++++++++++++++++++ mdl/executor/cmd_mermaid.go | 35 +++++--------- mdl/executor/cmd_microflow_elk.go | 14 +++--- mdl/executor/cmd_microflows_format_action.go | 36 +++----------- mdl/executor/cmd_page_wireframe.go | 11 +---- mdl/executor/cmd_pages_show.go | 14 ++---- mdl/executor/cmd_validationrules.go | 6 +-- 9 files changed, 90 insertions(+), 104 deletions(-) diff --git a/mdl/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index bad2367c5e..175143e988 100644 --- a/mdl/executor/cmd_diff_mdl.go +++ b/mdl/executor/cmd_diff_mdl.go @@ -204,6 +204,7 @@ func associationStmtToMDL(ctx *ExecContext, s *ast.CreateAssociationStmt) string // entityToMDL converts a project entity to MDL text func entityToMDL(ctx *ExecContext, moduleName string, entity *domainmodel.Entity, dm *domainmodel.DomainModel) string { var lines []string + lang := describeDefaultLanguage(ctx) // Documentation if entity.Documentation != "" { @@ -254,20 +255,14 @@ func entityToMDL(ctx *ExecContext, moduleName string, entity *domainmodel.Entity for _, vr := range attrValidations { if vr.Type == "Required" { constraints.WriteString(" not null") - if vr.ErrorMessage != nil { - errMsg := vr.ErrorMessage.GetTranslation("en_US") - if errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) - } + if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { + constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) } } if vr.Type == "Unique" { constraints.WriteString(" unique") - if vr.ErrorMessage != nil { - errMsg := vr.ErrorMessage.GetTranslation("en_US") - if errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) - } + if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { + constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) } } } diff --git a/mdl/executor/cmd_entities_describe.go b/mdl/executor/cmd_entities_describe.go index 6424c19201..9e4a860429 100644 --- a/mdl/executor/cmd_entities_describe.go +++ b/mdl/executor/cmd_entities_describe.go @@ -225,6 +225,8 @@ func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { return mdlerrors.NewBackend("get domain model", err) } + lang := describeDefaultLanguage(ctx) + for _, entity := range dm.Entities { if entity.Name == name.Name { // Output JavaDoc documentation if present @@ -290,20 +292,14 @@ func describeEntity(ctx *ExecContext, name ast.QualifiedName) error { for _, vr := range attrValidations { if vr.Type == "Required" { constraints.WriteString(" not null") - if vr.ErrorMessage != nil { - errMsg := vr.ErrorMessage.GetTranslation("en_US") - if errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) - } + if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { + constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) } } if vr.Type == "Unique" { constraints.WriteString(" unique") - if vr.ErrorMessage != nil { - errMsg := vr.ErrorMessage.GetTranslation("en_US") - if errMsg != "" { - constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) - } + if errMsg := pickTextTranslation(vr.ErrorMessage, lang); errMsg != "" { + constraints.WriteString(fmt.Sprintf(" error '%s'", errMsg)) } } } diff --git a/mdl/executor/cmd_enumerations_caption_language_test.go b/mdl/executor/cmd_enumerations_caption_language_test.go index b674f083ef..86f7967ee1 100644 --- a/mdl/executor/cmd_enumerations_caption_language_test.go +++ b/mdl/executor/cmd_enumerations_caption_language_test.go @@ -28,6 +28,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" "github.com/mendixlabs/mxcli/mdl/backend/mock" "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" ) // enumWithCaptions builds a one-value enumeration whose caption carries the @@ -142,3 +143,49 @@ func TestPickTextTranslation_FallbackIsDeterministic(t *testing.T) { t.Errorf("fallback = %q, want the lowest language code's text %q", first, "Neu") } } + +// The same hardcoded "en_US" read sat at every other DESCRIBE text site that +// #702's widget sweep did not reach. They share one helper now, so this covers +// the sites the reported bug did not name but would have reached next: an +// entity's validation-rule feedback is dropped from `describe entity` exactly +// the way an enumeration caption was. +func TestDescribeEntity_ValidationMessageFollowsProjectLanguage_Issue1113(t *testing.T) { + mod := mkModule("Sales") + attr := &domainmodel.Attribute{ + BaseElement: model.BaseElement{ID: nextID("attr")}, + Name: "Reference", + Type: &domainmodel.StringAttributeType{Length: 50}, + } + entity := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: nextID("ent")}, + Name: "Order", + Persistable: true, + Attributes: []*domainmodel.Attribute{attr}, + ValidationRules: []*domainmodel.ValidationRule{{ + BaseElement: model.BaseElement{ID: nextID("vr")}, + AttributeID: attr.ID, + Type: "Required", + ErrorMessage: &model.Text{Translations: map[string]string{"nl_NL": "Referentie is verplicht"}}, + }}, + } + dm := mkDomainModel(mod.ID, entity) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{ + Language: &model.LanguageSettings{DefaultLanguageCode: "nl_NL"}, + }, nil + }, + } + + ctx, buf := newMockCtx(t, withBackend(mb)) + assertNoError(t, describeEntity(ctx, ast.QualifiedName{Module: "Sales", Name: "Order"})) + + out := buf.String() + if !strings.Contains(out, "not null error 'Referentie is verplicht'") { + t.Errorf("validation feedback lost to the en_US lookup (issue #1113):\n%s", out) + } +} diff --git a/mdl/executor/cmd_mermaid.go b/mdl/executor/cmd_mermaid.go index 869bc2d3fa..4250a501ac 100644 --- a/mdl/executor/cmd_mermaid.go +++ b/mdl/executor/cmd_mermaid.go @@ -342,7 +342,7 @@ func renderFlowMermaid(ctx *ExecContext, oc *microflows.MicroflowObjectCollectio } // Collect detail lines for this node - if details := mermaidActivityDetails(obj, entityNames); len(details) > 0 { + if details := mermaidActivityDetails(obj, entityNames, describeDefaultLanguage(ctx)); len(details) > 0 { nodeInfo[id] = details } } @@ -616,10 +616,10 @@ func mermaidActionLabel(a *microflows.ActionActivity, entityNames map[model.ID]s // mermaidActivityDetails returns detailed property lines for a microflow activity node. // These lines are emitted as metadata for the webview to show on expand/click. -func mermaidActivityDetails(obj microflows.MicroflowObject, entityNames map[model.ID]string) []string { +func mermaidActivityDetails(obj microflows.MicroflowObject, entityNames map[model.ID]string, lang string) []string { switch a := obj.(type) { case *microflows.ActionActivity: - return mermaidActionDetails(a, entityNames) + return mermaidActionDetails(a, entityNames, lang) case *microflows.ExclusiveSplit: var lines []string if a.Caption != "" { @@ -654,7 +654,7 @@ func mermaidActivityDetails(obj microflows.MicroflowObject, entityNames map[mode } // mermaidActionDetails returns detailed property lines for an action activity. -func mermaidActionDetails(a *microflows.ActionActivity, entityNames map[model.ID]string) []string { +func mermaidActionDetails(a *microflows.ActionActivity, entityNames map[model.ID]string, lang string) []string { if a.Action == nil { return nil } @@ -802,7 +802,7 @@ func mermaidActionDetails(a *microflows.ActionActivity, entityNames map[model.ID lines = append(lines, "Type: "+string(act.Type)) } if act.Template != nil { - if msg := mermaidTextPreview(act.Template); msg != "" { + if msg := mermaidTextPreview(act.Template, lang); msg != "" { lines = append(lines, "Message: "+mermaidTruncate(msg, 60)) } } @@ -821,7 +821,7 @@ func mermaidActionDetails(a *microflows.ActionActivity, entityNames map[model.ID lines = append(lines, "Target: "+target) } if act.Template != nil { - if msg := mermaidTextPreview(act.Template); msg != "" { + if msg := mermaidTextPreview(act.Template, lang); msg != "" { lines = append(lines, "Message: "+mermaidTruncate(msg, 60)) } } @@ -834,7 +834,7 @@ func mermaidActionDetails(a *microflows.ActionActivity, entityNames map[model.ID lines = append(lines, "Node: "+act.LogNodeName) } if act.MessageTemplate != nil { - if msg := mermaidTextPreview(act.MessageTemplate); msg != "" { + if msg := mermaidTextPreview(act.MessageTemplate, lang); msg != "" { lines = append(lines, "Message: "+mermaidTruncate(msg, 60)) } } @@ -950,21 +950,12 @@ func mermaidMemberName(mc *microflows.MemberChange) string { return name } -// mermaidTextPreview extracts the first non-empty translation from a model.Text. -func mermaidTextPreview(t *model.Text) string { - if t == nil { - return "" - } - // Try English first, then any language - if msg, ok := t.Translations["en_US"]; ok && msg != "" { - return strings.TrimSpace(msg) - } - for _, msg := range t.Translations { - if msg != "" { - return strings.TrimSpace(msg) - } - } - return "" +// mermaidTextPreview renders a model.Text in the project's language. It asked +// for "en_US" first and then ranged the map, so a Dutch project's diagram was +// labelled in English when both existed and in a language that changed between +// runs when neither did (mendixlabs/mxcli#1113). +func mermaidTextPreview(t *model.Text, lang string) string { + return strings.TrimSpace(pickTextTranslation(t, lang)) } // mermaidCaseLabel extracts a display label from a CaseValue. diff --git a/mdl/executor/cmd_microflow_elk.go b/mdl/executor/cmd_microflow_elk.go index 45589d4c4b..3021179f1e 100644 --- a/mdl/executor/cmd_microflow_elk.go +++ b/mdl/executor/cmd_microflow_elk.go @@ -173,7 +173,7 @@ func buildFlowELK(ctx *ExecContext, in flowELKInput) error { // Build nodes — loops become compound nodes with children for _, obj := range in.ObjectCollection.Objects { - node := buildMicroflowELKNodeHierarchical(obj, in.EntityNames, 0) + node := buildMicroflowELKNodeHierarchical(obj, in.EntityNames, describeDefaultLanguage(ctx), 0) data.Nodes = append(data.Nodes, node) } @@ -186,13 +186,13 @@ func buildFlowELK(ctx *ExecContext, in flowELKInput) error { return emitMicroflowELK(ctx, data) } -func buildMicroflowELKNode(obj microflows.MicroflowObject, entityNames map[model.ID]string) microflowELKNode { +func buildMicroflowELKNode(obj microflows.MicroflowObject, entityNames map[model.ID]string, lang string) microflowELKNode { id := "node-" + string(obj.GetID()) label := mermaidActivityLabel(obj, entityNames) // Un-escape Mermaid-specific escaping label = strings.ReplaceAll(label, "#quot;", "\"") - details := mermaidActivityDetails(obj, entityNames) + details := mermaidActivityDetails(obj, entityNames, lang) // Un-escape details too for i, d := range details { details[i] = strings.ReplaceAll(d, "#quot;", "\"") @@ -214,17 +214,17 @@ func buildMicroflowELKNode(obj microflows.MicroflowObject, entityNames map[model // buildMicroflowELKNodeHierarchical builds an ELK node, handling LoopedActivity // as a compound node with children (loop body objects) and inner edges. -func buildMicroflowELKNodeHierarchical(obj microflows.MicroflowObject, entityNames map[model.ID]string, depth int) microflowELKNode { +func buildMicroflowELKNodeHierarchical(obj microflows.MicroflowObject, entityNames map[model.ID]string, lang string, depth int) microflowELKNode { loop, isLoop := obj.(*microflows.LoopedActivity) if !isLoop || loop.ObjectCollection == nil || len(loop.ObjectCollection.Objects) == 0 { - return buildMicroflowELKNode(obj, entityNames) + return buildMicroflowELKNode(obj, entityNames, lang) } // Build compound loop node id := "node-" + string(loop.GetID()) label := mermaidActivityLabel(obj, entityNames) label = strings.ReplaceAll(label, "#quot;", "\"") - details := mermaidActivityDetails(obj, entityNames) + details := mermaidActivityDetails(obj, entityNames, lang) for i, d := range details { details[i] = strings.ReplaceAll(d, "#quot;", "\"") } @@ -240,7 +240,7 @@ func buildMicroflowELKNodeHierarchical(obj microflows.MicroflowObject, entityNam // Add children (recursively handle nested loops) for _, childObj := range loop.ObjectCollection.Objects { - child := buildMicroflowELKNodeHierarchical(childObj, entityNames, depth+1) + child := buildMicroflowELKNodeHierarchical(childObj, entityNames, lang, depth+1) node.Children = append(node.Children, child) } diff --git a/mdl/executor/cmd_microflows_format_action.go b/mdl/executor/cmd_microflows_format_action.go index 744958f7b6..73604bb009 100644 --- a/mdl/executor/cmd_microflows_format_action.go +++ b/mdl/executor/cmd_microflows_format_action.go @@ -556,16 +556,8 @@ func formatAction( node = defaultLogNodeExpression } message := "'Message'" - if a.MessageTemplate != nil && len(a.MessageTemplate.Translations) > 0 { - // Get message text from template (prefer en_US, fallback to any) - for _, text := range a.MessageTemplate.Translations { - message = text - break - } - if text, ok := a.MessageTemplate.Translations["en_US"]; ok { - message = text - } - message = mdlQuote(message) + if text := pickTextTranslation(a.MessageTemplate, describeDefaultLanguage(ctx)); text != "" { + message = mdlQuote(text) } // Build WITH clause if there are template parameters @@ -774,16 +766,8 @@ func formatAction( msgType = "Information" } message := "'...'" - if a.Template != nil && len(a.Template.Translations) > 0 { - // Get message text from template (prefer en_US, fallback to any) - for _, text := range a.Template.Translations { - message = text - break - } - if text, ok := a.Template.Translations["en_US"]; ok { - message = text - } - message = mdlQuote(message) + if text := pickTextTranslation(a.Template, describeDefaultLanguage(ctx)); text != "" { + message = mdlQuote(text) } result := fmt.Sprintf("show message %s type %s", message, msgType) if len(a.TemplateParameters) > 0 { @@ -811,17 +795,9 @@ func formatAction( return result + ";" case *microflows.ValidationFeedbackAction: - // Get the message text from template translations (prefer en_US, fallback to any) msgText := "'...'" - if a.Template != nil && len(a.Template.Translations) > 0 { - for _, text := range a.Template.Translations { - msgText = text - break - } - if text, ok := a.Template.Translations["en_US"]; ok { - msgText = text - } - msgText = mdlQuote(msgText) + if text := pickTextTranslation(a.Template, describeDefaultLanguage(ctx)); text != "" { + msgText = mdlQuote(text) } // Build attribute path from variable and attribute name // AttributeName format: Module.Entity.Attribute diff --git a/mdl/executor/cmd_page_wireframe.go b/mdl/executor/cmd_page_wireframe.go index 3be812f1bd..87f97f8673 100644 --- a/mdl/executor/cmd_page_wireframe.go +++ b/mdl/executor/cmd_page_wireframe.go @@ -132,16 +132,7 @@ func PageWireframeJSON(ctx *ExecContext, name string) error { qualifiedName := modName + "." + foundPage.Name // Extract page metadata - title := "" - if foundPage.Title != nil { - title = foundPage.Title.GetTranslation("en_US") - if title == "" { - for _, text := range foundPage.Title.Translations { - title = text - break - } - } - } + title := pickTextTranslation(foundPage.Title, describeDefaultLanguage(ctx)) layoutName := "" rawData, _ := ctx.Backend.GetRawUnit(foundPage.ID) diff --git a/mdl/executor/cmd_pages_show.go b/mdl/executor/cmd_pages_show.go index 2ff56df1a9..376600ce4c 100644 --- a/mdl/executor/cmd_pages_show.go +++ b/mdl/executor/cmd_pages_show.go @@ -24,6 +24,8 @@ func listPages(ctx *ExecContext, moduleName string) error { return mdlerrors.NewBackend("list pages", err) } + lang := describeDefaultLanguage(ctx) + // Collect rows type row struct { qualifiedName string @@ -43,17 +45,7 @@ func listPages(ctx *ExecContext, moduleName string) error { if moduleName == "" || modName == moduleName { qualifiedName := modName + "." + p.Name folderPath := h.BuildFolderPath(p.ContainerID) - title := "" - if p.Title != nil { - // Try to get English title first, then any available translation - title = p.Title.GetTranslation("en_US") - if title == "" { - for _, t := range p.Title.Translations { - title = t - break - } - } - } + title := pickTextTranslation(p.Title, lang) url := p.URL rows = append(rows, row{qualifiedName, modName, p.Name, p.Excluded, folderPath, title, url, len(p.Parameters)}) diff --git a/mdl/executor/cmd_validationrules.go b/mdl/executor/cmd_validationrules.go index 48e1747f9b..b45f00f42b 100644 --- a/mdl/executor/cmd_validationrules.go +++ b/mdl/executor/cmd_validationrules.go @@ -135,6 +135,7 @@ func validationRuleInfoFor(ctx *ExecContext, s *ast.CreateValidationRuleStmt) (d // the read is reported as a comment rather than skipped: silence would read as // "this entity has no such rule", which is the failure this whole area is about. func outputEntityValidationRules(ctx *ExecContext, entity *domainmodel.Entity, moduleName, entityName string, attrNames map[model.ID]string) { + lang := describeDefaultLanguage(ctx) for _, vr := range entity.ValidationRules { if vr == nil || (vr.Type != "RegEx" && vr.Type != "Range") { continue @@ -158,10 +159,7 @@ func outputEntityValidationRules(ctx *ExecContext, entity *domainmodel.Entity, m continue } - feedback := "" - if vr.ErrorMessage != nil { - feedback = vr.ErrorMessage.GetTranslation("en_US") - } + feedback := pickTextTranslation(vr.ErrorMessage, lang) fmt.Fprintf(ctx.Output, "\ncreate validation rule for %s\n %s\n feedback '%s';\n", target, constraint, escapeMDLString(feedback)) } From 75da19cd11579e8432d877283f16e8c23f80dc11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:10:30 +0000 Subject: [PATCH 4/7] Make workflows and view entities the default modelling choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are first-class in Mendix and both get reinvented in microflows by default, because the microflow version works: it passes `check`, it builds, and nothing flags it. No command can prompt for either — `lint` cannot know a Status attribute is standing in for a state machine — so the instruction has to be in the guidance or it reaches an agent only when the user already knew to ask. - A business process with human steps is a WORKFLOW, not a status attribute plus microflows: the state machine, the user-task inbox, assignment and targeting, timers and boundary events, and a definition the business can read are otherwise all hand-written, and the process stops being inspectable. - An aggregation is a VIEW ENTITY (OQL the database executes, Mendix 10.18+), not a microflow that retrieves every row to produce one number — which gets slower exactly as the app succeeds. Stated in all three descriptions of the procedure, as the gates already are: - generated CLAUDE.md/AGENTS.md, under "Conventions no command will tell you" (5,578 bytes, within the 6,000-byte budget); - bootstrap-app, as two interview follow-ups on Q4 and a section in the model proposal, where the choice is actually made; - the bootstrap-prompt docs page. Pointers verified rather than assumed: `mxcli syntax workflow` exists, but there is no view-entity topic in the syntax registry (`syntax oql` is runtime queries), so view entities point at the `write-oql-queries` skill. TestModellingDefaultsAreStatedEverywhere holds the three together. Control: replacing "view entity" in the docs page fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HK2imrM8M5UbzhTvttb5YC --- .claude/skills/mendix/bootstrap-app/SKILL.md | 35 +++++++++++++++++++- cmd/mxcli/init_claudemd.go | 6 ++++ cmd/mxcli/init_claudemd_gates_test.go | 32 ++++++++++++++++++ docs-site/src/tools/bootstrap-prompt.md | 10 +++++- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index e6b69f65ae..4754b87462 100644 --- a/.claude/skills/mendix/bootstrap-app/SKILL.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -51,7 +51,11 @@ it is building. below is derived from this. 4. **What does it keep track of?** Three to six nouns that will become entities, and a word on how they relate (e.g. "a Job has many Visits; each Visit has Photos"). For - a solution, also ask which app owns each noun. + a solution, also ask which app owns each noun. Ask two follow-ups here, because + both change the model rather than decorate it: does anything go through **steps + someone has to act on** (approval, hand-off, review, a deadline), and is there a + **number or count across records** anyone needs to see. The first is a workflow and + the second a view entity — see "Two choices to make deliberately" below. 5. **Who logs in?** The user roles, and roughly what each may do (e.g. "Requester creates and sees their own; Approver sees everything and approves"). 6. **Look and feel.** One of the bundled themes: `signal` (light, high contrast), @@ -295,6 +299,35 @@ named after it. From the brief, propose in chat: - for a solution: which app owns each entity, and what crosses the boundary — publish only what the other app actually needs +### Two choices to make deliberately — the lazy answer is wrong both times + +Both of these are first-class in Mendix and both are easy to reinvent in microflows, +because the microflow version *works*: it passes `check`, it builds, and nothing +flags it. The cost lands later, on someone else. + +- **A business process with human steps is a `WORKFLOW`**, not a status attribute and + a handful of microflows. Approvals, hand-offs, "someone has to look at this", + anything with a due date or a timer, anything that can sit waiting for days. You + get the state machine, the user-task inbox (`System.WorkflowUserTask`), assignment + and targeting, timers and boundary events, and a definition the business can read. + Rebuild it from status attributes and every one of those is yours to write and + maintain, and the process stops being inspectable — nobody can answer "where is + this request" except by reading microflows. `create workflow`; see + `mxcli syntax workflow` and the `write-workflows` skill. +- **An aggregation is a `VIEW ENTITY`**, not a microflow that retrieves the rows and + counts them. Totals, counts per group, a figure on a dashboard, a report, anything + joined across entities: a view entity is OQL the **database** executes — joins, + `GROUP BY`, `SUM`/`COUNT` — returning rows a page binds to directly. The microflow + version pulls every object into memory to produce one number, and it gets slower + exactly as the app succeeds, which is the worst possible failure curve. Needs + **Mendix 10.18+** (`show features` confirms it). `create view entity Mod.Name (…) + as ( select … )`; the `write-oql-queries` skill has the two rules that bite — + every column needs an `AS` alias, and `ORDER BY` needs a `LIMIT`. + +Name which of the two you are using **in the proposal**, with one line on why. Both +are cheap to choose now and expensive to retrofit: the pages, security rules and +tests all bind to whichever you picked. + Show it as **MDL the user can read**, and wait for their go-ahead before executing it. Name the elements the same way the plan's anchors do — if a requirement is anchored `@Module.ACT_Approve`, propose that name — so `./mxcli brain plan` starts diff --git a/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index 7d53060b9b..f5ffd9a0d0 100644 --- a/cmd/mxcli/init_claudemd.go +++ b/cmd/mxcli/init_claudemd.go @@ -213,6 +213,12 @@ func generateClaudeMD(projectName, mprFile string) string { w(" Quotes are stripped, so it is always safe, and it sidesteps every parser keyword.\n") w(" It does **not** exempt names Mendix itself reserves (" + bt + "Type" + bt + ", " + bt + "ID" + bt + ", " + bt + "CreatedDate" + bt + ") —\n") w(" those are rejected quoted or not.\n") + w("- **A business process with human steps is a " + bt + "WORKFLOW" + bt + "**, not a status attribute\n") + w(" plus microflows — you get the user-task inbox, assignment, timers and a definition\n") + w(" the business can read. " + bt + "mxcli syntax workflow" + bt + ", skill " + bt + "write-workflows" + bt + ".\n") + w("- **An aggregation is a " + bt + "VIEW ENTITY" + bt + "** (OQL, Mendix 10.18+) — not a microflow that\n") + w(" retrieves rows and counts them. The database does the work instead of pulling every\n") + w(" object into memory. Skill " + bt + "write-oql-queries" + bt + ".\n") w("- **A " + bt + "/** ... */" + bt + " comment before a statement sets that element's documentation.**\n") w("- **" + bt + "@Position(x, y)" + bt + " is optional** — mxcli places microflow activities, and\n") w(" " + bt + "./mxcli layout" + bt + " arranges the domain model.\n\n") diff --git a/cmd/mxcli/init_claudemd_gates_test.go b/cmd/mxcli/init_claudemd_gates_test.go index 850f45db85..9f23cafe74 100644 --- a/cmd/mxcli/init_claudemd_gates_test.go +++ b/cmd/mxcli/init_claudemd_gates_test.go @@ -117,3 +117,35 @@ func TestBootstrapAndDefaultBehaviourAgreeOnQualityAndPlan(t *testing.T) { "plus the template's with nothing to subtract") } } + +// Two modelling choices are first-class in Mendix and get reinvented in +// microflows by default, because the microflow version passes `check`, builds, +// and is flagged by nothing: a business process with human steps (a WORKFLOW) +// and an aggregation (a VIEW ENTITY). Neither is something a command can +// prompt for — `lint` cannot know that a Status attribute is standing in for a +// state machine — so if the instruction is not in all three descriptions of +// the procedure, it reaches an agent only when the user already knows to ask, +// which is exactly when it is least needed. +func TestModellingDefaultsAreStatedEverywhere(t *testing.T) { + sources := map[string]string{ + "the generated CLAUDE.md": generateClaudeMD("Demo", "Demo.mpr"), + "the bootstrap-app skill": bootstrapSkill(t), + } + const docPath = "../../docs-site/src/tools/bootstrap-prompt.md" + doc, err := os.ReadFile(docPath) + if err != nil { + t.Fatalf("cannot read %s: %v", docPath, err) + } + sources[docPath] = string(doc) + + for _, want := range []struct{ phrase, why string }{ + {"workflow", "a process with human steps belongs in a workflow, not a status attribute plus microflows"}, + {"view entity", "an aggregation belongs in a view entity, not a microflow that retrieves every row to produce one number"}, + } { + for name, body := range sources { + if !strings.Contains(strings.ToLower(body), want.phrase) { + t.Errorf("%s does not mention %q — %s", name, want.phrase, want.why) + } + } + } +} diff --git a/docs-site/src/tools/bootstrap-prompt.md b/docs-site/src/tools/bootstrap-prompt.md index d61ffd29d8..1d9eaa1520 100644 --- a/docs-site/src/tools/bootstrap-prompt.md +++ b/docs-site/src/tools/bootstrap-prompt.md @@ -81,7 +81,15 @@ a longer prompt. template's, with nothing to subtract. See [mxcli report](mxcli-report.md) for how to read the six category scores. 7. **Proposes the model in MDL and waits** — module, entities, roles, pages — before - building anything. + building anything. Two of those choices it makes deliberately rather than by + default: a process with steps someone has to act on becomes a **workflow** (state + machine, user-task inbox, timers, a definition the business can read) rather than + a status attribute and some microflows, and a total or count across records + becomes a **view entity** — OQL the database executes — rather than a microflow + that retrieves every row to produce one number. Both are cheap to choose at the + proposal and expensive to retrofit, because the pages, security rules and tests + bind to whichever was picked. The same two rules are in the project's generated + `CLAUDE.md`, so later sessions apply them without being asked. For a solution repo it also covers the parts that bite: per-app ports, a hostname per app so the two apps do not share one cookie jar, the root SessionStart hook that From c9b9c45691753e1e52c6ea70c60dd60e5336f902 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:23:06 +0000 Subject: [PATCH 5/7] syntax: add view-entity topics, and say what a nanoflow cannot do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found while wiring the modelling defaults, one real and one not. VIEW ENTITY had no `mxcli syntax` topic at all — the registry knew the words only as a keyword on `errors.execution`. An agent told to prefer a view entity over a retrieve-and-count microflow, and told to ask the tool rather than this repo's docs, got nothing back. Three topics now, under domain-model: domain-model.view-entity what it is, when to reach for it, read-only, why ALTER does not apply domain-model.view-entity.oql both clause orders, association paths, and the four rules that bite domain-model.view-entity.association selecting an id under an alias IS the association Filed under domain-model rather than at the top level because a view entity is a domain-model document: it sits on the canvas, declares attributes, pages bind to it, and its associations come from its own OQL. The top-level `oql` topic is a different thing — running a query against a live runtime — and a sibling `view-entity` beside it would invite that confusion. Aliases (view-entity, view_entity, viewentity, view-entities) keep it reachable from the word people use; bare "view" is deliberately not one, since GRANT VIEW ON PAGE owns that word. Content is taken from measured behaviour in mdl-examples/bug-tests rather than restated from the skill: the derived-string-length rule (String(200) always, CE6770), the quoted-source / unquotable-alias split (MDL072, so an attribute can never be called Month), and the three consequences of the derived association (MDL080, CE6771, the module-level name clash). Every example was run through `mxcli check`. NANOFLOWS were not missing — microflow.nanoflow already existed, and `mxcli syntax nanoflow` resolves to it. What it said was "same syntax as microflow", which is the half that never causes a problem. It now lists what `mxcli check` actually refuses, read off nanoflow_validation.go rather than from memory: the ten disallowed activity families and every workflow action, the Binary return type, and the six activities that reject ON ERROR (CE6035) versus the ones that take it. One claim was wrong on the first pass and the grammar caught it: there IS a GRANT EXECUTE ON NANOFLOW. Verified by parsing it, along with CREATE OR REPLACE NANOFLOW, before the entry claimed either. Both pointers added in the previous commit now name the topic as well as the skill, which is what the generated CLAUDE.md's own rule asks for (5,606 bytes, within the 6,000-byte budget). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HK2imrM8M5UbzhTvttb5YC --- .claude/skills/mendix/bootstrap-app/SKILL.md | 7 +- cmd/mxcli/init_claudemd.go | 2 +- cmd/mxcli/syntax/features_domain_model.go | 149 +++++++++++++++++++ cmd/mxcli/syntax/features_microflow.go | 40 ++++- cmd/mxcli/syntax/registry.go | 7 + 5 files changed, 196 insertions(+), 9 deletions(-) diff --git a/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index 4754b87462..cc423375ee 100644 --- a/.claude/skills/mendix/bootstrap-app/SKILL.md +++ b/.claude/skills/mendix/bootstrap-app/SKILL.md @@ -321,8 +321,11 @@ flags it. The cost lands later, on someone else. version pulls every object into memory to produce one number, and it gets slower exactly as the app succeeds, which is the worst possible failure curve. Needs **Mendix 10.18+** (`show features` confirms it). `create view entity Mod.Name (…) - as ( select … )`; the `write-oql-queries` skill has the two rules that bite — - every column needs an `AS` alias, and `ORDER BY` needs a `LIMIT`. + as ( select … )` — see `mxcli syntax view-entity` for the shape, its `oql` and + `association` subtopics for the rules that bite (every column needs an `AS` + alias; `ORDER BY` needs a `LIMIT`; selecting an id under an alias makes an + *association*, not an attribute), and the `write-oql-queries` skill for worked + queries. Name which of the two you are using **in the proposal**, with one line on why. Both are cheap to choose now and expensive to retrofit: the pages, security rules and diff --git a/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index f5ffd9a0d0..e259705060 100644 --- a/cmd/mxcli/init_claudemd.go +++ b/cmd/mxcli/init_claudemd.go @@ -218,7 +218,7 @@ func generateClaudeMD(projectName, mprFile string) string { w(" the business can read. " + bt + "mxcli syntax workflow" + bt + ", skill " + bt + "write-workflows" + bt + ".\n") w("- **An aggregation is a " + bt + "VIEW ENTITY" + bt + "** (OQL, Mendix 10.18+) — not a microflow that\n") w(" retrieves rows and counts them. The database does the work instead of pulling every\n") - w(" object into memory. Skill " + bt + "write-oql-queries" + bt + ".\n") + w(" object into memory. " + bt + "mxcli syntax view-entity" + bt + ", skill " + bt + "write-oql-queries" + bt + ".\n") w("- **A " + bt + "/** ... */" + bt + " comment before a statement sets that element's documentation.**\n") w("- **" + bt + "@Position(x, y)" + bt + " is optional** — mxcli places microflow activities, and\n") w(" " + bt + "./mxcli layout" + bt + " arranges the domain model.\n\n") diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index f32ca52817..de35eeb79b 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -126,6 +126,155 @@ func init() { SeeAlso: []string{"domain-model.entity.create", "domain-model.types"}, }) + // --- View entity --- + // + // Filed under domain-model, not at the top level, because a view entity IS + // a domain-model document: it sits on the canvas, declares attributes, + // pages bind to it, and its associations are derived from its own OQL. The + // top-level `oql` topic is a different thing entirely — running a query + // against a live runtime — and a sibling `view-entity` beside it would + // invite exactly that confusion. + + Register(SyntaxFeature{ + Path: "domain-model.view-entity", + Summary: "VIEW ENTITY — an entity whose rows come from an OQL query the database runs", + Keywords: []string{ + "view entity", "view-entity", "oql view", "aggregation", "aggregate", + "group by", "sum", "count", "report", "dashboard", "totals", + "create view entity", "query performance", "read model", + }, + MinVersion: "10.18.0", + Syntax: "CREATE VIEW ENTITY [IF NOT EXISTS] Module.Name (\n" + + " Attr: Type,\n" + + " ...\n" + + ") AS ( );\n\n" + + "REACH FOR THIS INSTEAD OF A MICROFLOW whenever the answer is a total, a\n" + + "count per group, a figure on a dashboard, or anything joined across\n" + + "entities. The database does the work and returns rows a page binds to\n" + + "directly; the microflow version retrieves every object into memory to\n" + + "produce one number, and gets slower exactly as the app succeeds.\n\n" + + "A view entity is READ-ONLY: no create, change, delete or commit, and no\n" + + "plain association to or from one (mxbuild: CE6771 — see\n" + + "domain-model.view-entity.association for the form that works).\n\n" + + "ALTER ENTITY does not apply. Re-run CREATE OR MODIFY VIEW ENTITY with the\n" + + "whole definition: the attribute list and the OQL are one unit, and Mendix\n" + + "rejects a model where they disagree (CE6770 \"View Entity is out of sync\n" + + "with the OQL Query\").", + Example: "create view entity Sales.RevenueByRegion (\n" + + " Region: String(100),\n" + + " OrderCount: Integer,\n" + + " Revenue: Decimal\n" + + ") as (\n" + + " select c.Region as Region, count(o.ID) as OrderCount, sum(o.Amount) as Revenue\n" + + " from Sales.Order as o\n" + + " join o/Sales.Order_Customer/Sales.Customer as c\n" + + " group by c.Region\n" + + ");", + SeeAlso: []string{"domain-model.view-entity.oql", "domain-model.view-entity.association", "domain-model.entity", "oql"}, + }) + + Register(SyntaxFeature{ + Path: "domain-model.view-entity.oql", + Summary: "The OQL inside a view entity — clause order, aliases, and the length rule", + Keywords: []string{ + "oql", "view entity oql", "select", "from", "join", "group by", + "order by", "limit", "union", "alias", "as alias", "cast", + "MDL030", "MDL031", "MDL072", "CE0174", "CE6770", + }, + MinVersion: "10.18.0", + Syntax: "BOTH CLAUSE ORDERS ARE ACCEPTED — `select … from …` and Mendix's own\n" + + "from-first `from … join … group by … select …`.\n\n" + + "An association is walked with SLASHES, never dots:\n" + + " join o/Module.Order_Customer/Module.Customer as c\n\n" + + "FOUR RULES THAT BITE, each caught by `mxcli check` before a build:\n\n" + + "1. EVERY select column needs an `AS` alias, and the alias is the attribute\n" + + " name it fills (MDL030; mxbuild CE0174).\n" + + "2. ORDER BY requires a LIMIT. Prefer NEITHER, so the page or microflow\n" + + " consuming the view sorts and pages as it needs; use `ORDER BY … LIMIT n`\n" + + " only for a view that is intrinsically top-N (MDL030; CE0174).\n" + + "3. A DERIVED string column is String(200), always — a CAST to string, a\n" + + " string-returning CASE, any string expression. Declare it `String(200)`\n" + + " or mxbuild rejects the view with CE6770. Only a pass-through column\n" + + " inherits its source attribute's length (MDL031).\n" + + "4. A SOURCE may be double-quoted like SQL (`s.\"Month\"`), an ALIAS may not\n" + + " (MDL072). So a view attribute can never be called `Month` or `Year` —\n" + + " that one is renamed, not quoted.\n\n" + + "UNION / UNION ALL are supported and round-trip; column count and types must\n" + + "line up across branches, and an ORDER BY applies to the whole result.", + Example: "-- A reserved word as a SOURCE: quote it. The alias is renamed instead.\n" + + "create view entity Sales.SalesByMonth (\n" + + " MonthNo: Integer,\n" + + " Total: Decimal\n" + + ") as (\n" + + " select s.\"Month\" as MonthNo, sum(s.Amount) as Total\n" + + " from Sales.Order as s\n" + + " group by s.\"Month\"\n" + + ");\n\n" + + "-- An intrinsically top-N view: ORDER BY paired with LIMIT\n" + + "create view entity Sales.TopCustomers (\n" + + " Name: String(100),\n" + + " Revenue: Decimal\n" + + ") as (\n" + + " select c.Name as Name, sum(o.Amount) as Revenue\n" + + " from Sales.Order as o\n" + + " join o/Sales.Order_Customer/Sales.Customer as c\n" + + " group by c.Name\n" + + " order by Revenue desc\n" + + " limit 100\n" + + ");", + SeeAlso: []string{"domain-model.view-entity", "domain-model.view-entity.association", "oql"}, + }) + + Register(SyntaxFeature{ + Path: "domain-model.view-entity.association", + Summary: "A view entity's associations are DERIVED from its OQL — select an id under an alias", + Keywords: []string{ + "view entity association", "oql association", "select id as", + "OqlViewAssociationSource", "CE6771", "CE6770", "MDL080", + }, + MinVersion: "10.18.0", + Syntax: "SELECTING A PERSISTENT ENTITY'S ID UNDER AN ALIAS CREATES AN ASSOCIATION,\n" + + "named after the alias. There is no second statement, and the id column is\n" + + "NOT one of the view entity's attributes:\n\n" + + " select m.ID as MeterRef, sum(r.Kwh) as TotalKwh\n" + + " -> association MeterRef, attribute TotalKwh\n\n" + + "Three things follow, each of which is refused by `mxcli check` rather than\n" + + "left to the build:\n\n" + + "- DO NOT declare the alias in the attribute list. `MeterRef: Module.Meter`\n" + + " parses (a bare qualified name is how MDL spells an enumeration type) and\n" + + " used to be stored as one — mxbuild: CE1613 (MDL080).\n" + + "- DO NOT write CREATE ASSOCIATION with a view entity at either end. Mendix\n" + + " refuses it outright: CE6771 \"It is not possible to create associations\n" + + " to/from View Entities.\"\n" + + "- THE ALIAS IS A MODULE-LEVEL NAME. It cannot collide with an entity or\n" + + " enumeration in the same module, case-insensitively — Mendix: \"Duplicate\n" + + " name … Entities, associations and enumerations cannot share names.\"\n\n" + + "The alternative is often better: CAST the id to a string and keep it as a\n" + + "plain attribute. That is one SQL statement instead of two and materialises\n" + + "no objects in the client, and the id is still there to look the real object\n" + + "up with.", + Example: "-- One attribute declared, TWO select columns: the id column is the association\n" + + "create view entity Trends.MeterTotals (\n" + + " TotalKwh: Decimal\n" + + ") as (\n" + + " from Trends.Reading as r\n" + + " join r/Trends.Reading_Meter/Trends.Meter as m\n" + + " group by m.ID\n" + + " select m.ID as MeterRef, sum(r.Kwh) as TotalKwh\n" + + ");\n\n" + + "-- The flat alternative: the id as a String(200) attribute, no association\n" + + "create view entity Trends.MeterTotalsFlat (\n" + + " MeterId: String(200),\n" + + " TotalKwh: Decimal\n" + + ") as (\n" + + " from Trends.Reading as r\n" + + " join r/Trends.Reading_Meter/Trends.Meter as m\n" + + " group by m.ID\n" + + " select cast(m.ID as string) as MeterId, sum(r.Kwh) as TotalKwh\n" + + ");", + SeeAlso: []string{"domain-model.view-entity", "domain-model.view-entity.oql", "domain-model.association"}, + }) + // --- Association --- Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/features_microflow.go b/cmd/mxcli/syntax/features_microflow.go index bd8198162b..40680730a4 100644 --- a/cmd/mxcli/syntax/features_microflow.go +++ b/cmd/mxcli/syntax/features_microflow.go @@ -359,14 +359,42 @@ func init() { Register(SyntaxFeature{ Path: "microflow.nanoflow", - Summary: "CREATE NANOFLOW — client-side logic, same syntax as microflow", + Summary: "CREATE NANOFLOW — client-side logic; microflow syntax minus the server-only half", Keywords: []string{ - "nanoflow", "create nanoflow", "client-side", - "offline", "client logic", + "nanoflow", "create nanoflow", "client-side", "runs in the browser", + "offline", "client logic", "disallowed in nanoflow", "nanoflow restrictions", }, - Syntax: "CREATE NANOFLOW Module.Name ($Param: Type) RETURNS Type AS $Result\nBEGIN\n \nEND;", - Example: "CREATE NANOFLOW MyModule.NF_ValidateInput ($Input: String)\nRETURNS Boolean AS $IsValid\nBEGIN\n IF $Input = empty THEN\n VALIDATION FEEDBACK $Input MESSAGE 'Required';\n RETURN false;\n END IF;\n RETURN true;\nEND;", - SeeAlso: []string{"microflow.create"}, + Syntax: "CREATE [OR REPLACE] NANOFLOW Module.Name ($Param: Type)\n" + + "RETURNS Type AS $Result\nBEGIN\n \nEND;\n\n" + + "The body is microflow syntax — every topic under `microflow` applies —\n" + + "MINUS what cannot run in the browser. `mxcli check` refuses each of these\n" + + "before a build, nested inside IF/LOOP/WHILE and error-handler bodies too:\n\n" + + " RAISE ERROR ErrorEvent has no nanoflow equivalent\n" + + " CALL JAVA ACTION server-side\n" + + " EXECUTE DATABASE QUERY server-side\n" + + " CALL EXTERNAL ACTION server-side\n" + + " CALL REST SERVICE / SEND REST REQUEST\n" + + " IMPORT FROM MAPPING / EXPORT TO MAPPING\n" + + " TRANSFORM JSON\n" + + " DOWNLOAD FILE\n" + + " SHOW HOME PAGE\n" + + " every WORKFLOW action (call, open, set task outcome, notify, lock, …)\n\n" + + "A Binary RETURN type is not allowed either.\n\n" + + "ON ERROR is not universal here. Six activities reject it — change, log,\n" + + "show page, close page, show message, validation feedback — because Mendix\n" + + "answers CE6035 \"Error handling type is not supported\"; a nanoflow activity\n" + + "aborts the flow on error by default, so drop the clause. The other\n" + + "activities (create, commit, retrieve, the calls, declare, set) take it.\n\n" + + "SYNCHRONIZE is the mirror image: allowed ONLY in a nanoflow (MDL057 flags\n" + + "it in a microflow) — see microflow.synchronize.\n\n" + + "Security is the same shape as a microflow's:\n" + + " GRANT EXECUTE ON NANOFLOW Module.Name TO Module.Role;", + Example: "CREATE NANOFLOW MyModule.NF_ValidateInput ($Input: String)\nRETURNS Boolean AS $IsValid\nBEGIN\n IF $Input = empty THEN\n VALIDATION FEEDBACK $Input MESSAGE 'Required';\n RETURN false;\n END IF;\n RETURN true;\nEND;\n\n" + + "-- Server-side work belongs behind a microflow call, which IS allowed\n" + + "CREATE NANOFLOW MyModule.NF_Submit ($Order: Sales.Order)\nBEGIN\n" + + " CALL MICROFLOW MyModule.ACT_SubmitOrder (Order = $Order);\n" + + " CLOSE PAGE;\nEND;", + SeeAlso: []string{"microflow.create", "microflow.synchronize", "microflow.error-handling"}, }) Register(SyntaxFeature{ diff --git a/cmd/mxcli/syntax/registry.go b/cmd/mxcli/syntax/registry.go index 1e1537265f..a8ce11dfb2 100644 --- a/cmd/mxcli/syntax/registry.go +++ b/cmd/mxcli/syntax/registry.go @@ -47,6 +47,13 @@ var topicAliases = map[string]string{ "constants": "domain-model.constant", "association": "domain-model.association", "associations": "domain-model.association", + // "view" alone is deliberately absent: GRANT VIEW ON PAGE owns that word in + // MDL, so the alias has to carry "entity" to be unambiguous. + "view-entity": "domain-model.view-entity", + "view_entity": "domain-model.view-entity", + "viewentity": "domain-model.view-entity", + "view-entities": "domain-model.view-entity", + "viewentities": "domain-model.view-entity", // Plural aliases "microflows": "microflow", "pages": "page", From c4d69ae19fa65dad899b6cfe93a5bcd3311b3121 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:33:55 +0000 Subject: [PATCH 6/7] syntax: document ALTER STYLING and UPDATE WIDGETS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of the registry against the grammar — every createStatement, alterStatement, dropStatement, dqlStatement and utilityStatement alternative matched against the 163 registered topics — found the CREATE and DROP surfaces fully covered and two shipped, MODEL-MUTATING statements with no registry presence at all: ALTER STYLING ON PAGE|SNIPPET X WIDGET w SET … | CLEAR DESIGN PROPERTIES UPDATE WIDGETS SET … WHERE … [IN Module] [DRY RUN] Both are implemented (mdl/executor/cmd_styling.go, cmd_widgets.go) and both are the in-place alternative to rewriting a whole page, which is exactly the case where being undiscoverable costs the most: an agent that cannot find them reaches for CREATE OR REPLACE PAGE, whose diff is the entire document and which drops anything MDL cannot yet spell. ALTER STYLING joins page.styling rather than getting its own path: the subject is one subject, and the topic previously documented only the half written inside CREATE PAGE. UPDATE WIDGETS gets page.update-widgets, with SHOW WIDGETS beside it since they share the WHERE grammar and the read-only one is how you check the other's pattern. Each claim read off the source rather than assumed: WHERE is mandatory on UPDATE (the grammar has no optional form), DRY RUN threads through to updateWidgetsInContainer and writes nothing, and the catalog is not refreshed by an update — the executor prints that itself, so the topic says it. Both examples were run through `mxcli check`, and the registry's TestExamplesParse now guards them. Not added, as a deliberate line: the session/REPL utilities the grammar also carries — LINT, SHOW LINT RULES, EXECUTE SCRIPT, EXECUTE RUNTIME, USE, INTROSPECT API, DEBUG, session SET. They drive the tool rather than author the model, and each has a CLI equivalent that is documented where CLI commands are documented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HK2imrM8M5UbzhTvttb5YC --- cmd/mxcli/syntax/features_page.go | 78 +++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 0d8e938b64..1f855c38d4 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -269,15 +269,85 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { Register(SyntaxFeature{ Path: "page.styling", - Summary: "CSS classes, inline styles, dynamic (runtime-computed) classes, and Atlas design properties on widgets", + Summary: "CSS classes, inline styles, dynamic classes and Atlas design properties — written inline, or ALTERed in place", Keywords: []string{ "class", "style", "css", "design properties", "atlas", "spacing", "full width", "dynamic classes", "dynamicclasses", "conditional class", "runtime class", + "alter styling", "restyle", "clear design properties", "restyle a widget", }, - Syntax: "Class: 'css-class-name' -- static CSS classes\nStyle: 'color: red; padding: 8px;' -- inline CSS\nDynamicClasses: '' -- runtime-computed classes (stacks on Class)\nDesignProperties: ['Spacing top': 'Large']\nDesignProperties: ['Full width': ON]", - Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: 'if $currentObject/Priority = ''High'' then ''card-danger'' else ''card-normal'''\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}", - SeeAlso: []string{"page.widgets"}, + Syntax: "ON A WIDGET, inside CREATE PAGE / CREATE SNIPPET:\n\n" + + " Class: 'css-class-name' -- static CSS classes\n" + + " Style: 'color: red; padding: 8px;' -- inline CSS\n" + + " DynamicClasses: '' -- runtime-computed (stacks on Class)\n" + + " DesignProperties: ['Spacing top': 'Large']\n" + + " DesignProperties: ['Full width': ON]\n\n" + + "ON A PAGE THAT ALREADY EXISTS, without rewriting it:\n\n" + + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + + " SET Class = 'css-class', Style = 'css', 'Design property' = 'Value'|ON|OFF;\n\n" + + " ALTER STYLING ON PAGE|SNIPPET Module.Name WIDGET \n" + + " CLEAR DESIGN PROPERTIES;\n\n" + + "The widget is named by its MDL NAME — the identifier after the widget\n" + + "keyword (`ACTIONBUTTON btnSave`), not its caption. `DESCRIBE PAGE` prints\n" + + "the names.\n\n" + + "A bare `Class =` REPLACES the widget's classes rather than adding to them.\n" + + "Read the current value first if you meant to append.\n\n" + + "Reach for ALTER STYLING rather than CREATE OR REPLACE PAGE whenever only\n" + + "the look changes: replacing the page rewrites every widget in it, so the\n" + + "diff is the whole document and anything MDL cannot yet spell is lost.", + Example: "CONTAINER ctn (\n Class: 'my-card',\n DynamicClasses: 'if $currentObject/Priority = ''High'' then ''card-danger'' else ''card-normal'''\n) {\n DYNAMICTEXT txt (Content: 'Styled text')\n}\n\n" + + "-- Restyle one widget on a page that already exists\n" + + "alter styling on page Sales.OrderOverview widget btnSave\n" + + " set Class = 'btn-primary', 'Spacing top' = 'Large';\n\n" + + "-- Back to Atlas defaults\n" + + "alter styling on snippet Sales.OrderRow widget ctnMain\n" + + " clear design properties;", + SeeAlso: []string{"page.widgets", "page.alter", "page.update-widgets"}, + }) + + Register(SyntaxFeature{ + Path: "page.update-widgets", + Summary: "SHOW / UPDATE WIDGETS — find widgets across every page, and set a property on all of them", + Keywords: []string{ + "show widgets", "update widgets", "bulk", "bulk update", "across pages", + "widgettype", "dry run", "every page", "all pages", "sweep", "mass edit", + }, + Syntax: "SHOW WIDGETS [WHERE [AND ...]] [IN Module];\n\n" + + "UPDATE WIDGETS\n" + + " SET 'property' = [, 'property' = ...]\n" + + " WHERE [AND ...]\n" + + " [IN Module]\n" + + " [DRY RUN];\n\n" + + "A condition is `WidgetType = 'x'` or `WidgetType LIKE '%x%'`, or any other\n" + + "property name against `=` / `LIKE`. Values are strings, numbers, booleans\n" + + "or NULL. The property name is QUOTED — it is the widget's own key, as\n" + + "`DESCRIBE WIDGET` prints it, not an MDL keyword.\n\n" + + "WHERE IS MANDATORY on UPDATE. There is no \"all widgets\" form, because the\n" + + "statement rewrites every page a match lands on.\n\n" + + "RUN IT WITH `DRY RUN` FIRST. It reports the matches and the containers they\n" + + "sit in and writes nothing — the only way to see what a pattern actually\n" + + "selects before it has selected it. `SHOW WIDGETS` with the same WHERE\n" + + "answers the same question read-only.\n\n" + + "Needs a full catalog, which the statement builds itself, and a project open\n" + + "for writing. The catalog is NOT refreshed by the update — run\n" + + "`REFRESH CATALOG FULL FORCE` afterwards, or the next query answers from the\n" + + "model as it was before.\n\n" + + "EXPERIMENTAL: this reaches into pluggable-widget property bags, where a key\n" + + "that does not belong to a widget's schema is what CE0463 is made of. Check\n" + + "the build afterwards.", + Example: "-- What would match, read-only\n" + + "show widgets where WidgetType like '%combobox%' in Sales;\n\n" + + "-- What would change, still writing nothing\n" + + "update widgets\n" + + " set 'showLabel' = false\n" + + " where WidgetType like '%combobox%'\n" + + " dry run;\n\n" + + "-- Apply, scoped to one module\n" + + "update widgets\n" + + " set 'filterMode' = 'contains', 'labelWidth' = 4\n" + + " where WidgetType like '%DataGrid%'\n" + + " in Sales;", + SeeAlso: []string{"page.widget-describe", "page.alter", "page.styling"}, }) Register(SyntaxFeature{ From 318cf90cf224154ed149db58402f712f84728ca9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 19:50:42 +0000 Subject: [PATCH 7/7] fix: correct complex-type flattening against the TripPin contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flattening in the previous commit was verified against a synthetic two-property complex type in its own namespace, which passed `mx check` clean. The integration suite's live TripPin contract (mdl-examples/doctype-tests/10-odata-examples.mdl) found four defects in it, at 11 errors on mxbuild. TripPin has what the fixture lacked: a base complex type, two types derived from it, a nested complex property, an Edm.GeographyPoint, and both a top-level entity set and types derived from it. Each fix is measured on 11.12.1: 1. Mendix imports a complex type's OWN properties only. Flattening AirportLocation's inherited `Address` is CE6615 "does not exist in the OData service", while the same `Address` reached through Person.HomeAddress — typed `Location` directly — is accepted, and each derived type's own property (`Loc`, `BuildingInfo`) is accepted. So the line is inheritance, not path syntax. 2. `!strings.HasPrefix(t, "Edm.")` is not the supported-type test. Edm.GeographyPoint passes it and is CE6622 "The type of attribute 'Location_Loc' in the OData service is not supported." The importable primitives are now a closed set. 3. Filterable/Sortable follow the ENTITY SET, not a fixed answer. CE6630 fires in both directions: on Person (entity set People) a flattened attribute must be filterable, on Employee/Manager/Event (derived, no entity set) it must not. Manager.BossOffice is Manager's own property and still unfilterable, which rules out inheritance as the explanation. Creatable/Updatable stay unconditionally false. 4. Inherited and unsupported properties are reported by name and reason rather than dropped, like every other thing the import cannot map. TripPin now imports at 0 errors, down from 11. Each rule has a test with a control, including both directions of the filterability rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019FYdKPGnoWiq2VgNF6ZfX1 --- .../fix-issue/findings/mdl-executor.jsonl | 2 +- docs-site/src/reference/odata/README.md | 20 +- .../1118-odata-complextype-flattening.mdl | 14 ++ mdl/executor/cmd_contract.go | 53 ++++-- mdl/executor/cmd_contract_complextype_test.go | 179 +++++++++++++++++- mdl/types/edmx.go | 88 +++++++-- 6 files changed, 307 insertions(+), 49 deletions(-) diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 1d427e1949..168b73f2ea 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -637,4 +637,4 @@ {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A .def.json that maps a Data Grid 2 column filter's `linkedDs` produces a widget mxbuild rejects with CE0642 \"Property 'Datasource to Filter' is required\" — naming the very property the value was written into", "cause": "`linkedDs` is declared `isLinked=\"true\"` in widget.xml: the platform fills it from the containing DataGrid2, and mxbuild resolves it from the parent rather than reading what is stored. mxcli could not tell a linked datasource from an authorable one because IsLinked, though present in the template ValueType, was not carried into PropertyTypeIDEntry", "file": "`mdl/types/widget_property_type.go` (IsLinked), `modelsdk/widgets/loader.go`, `mdl/executor/widget_engine.go` (`refuseLinkedDataSourceMapping`)", "insight": "Before mapping a widget property, check `isLinked` in widget.xml — a linked property is the platform's to fill, the ADR-0005 'author only what the model owns' rule wearing a widget hat. Three cheap measurements settle it faster than reasoning: grep the shipped template's ValueType for IsLinked, dump the property off Studio Pro-authored widgets in testdata/expr-checker (5 of 5 store linkedDs empty), and mx check the correct shape (0 errors WITHOUT it). Beware the inverted signal: writing the value does NOT clear CE0642, so a failing check after writing it looks like the value is missing rather than unwanted. Across all widget packages in testdata, linkedDs is the ONLY linked datasource among the 8 multi-datasource widgets — DROPDOWNFILTER is single-source from MDL's side, ComboBox and the 6 charts are genuinely multi-source", "ce": ["CE0642"]} {"area": "mdl/executor", "date": "2026-09-16", "symptom": "A chart series given BOTH a static and a dynamic datasource writes its static x/y attributes against the DYNAMIC source's entity — mxbuild reports CE1613 \"The selected attribute 'CH.Forecast.Region' no longer exists.\"", "cause": "buildObjectListItem pre-resolves every datasource the item configures and dropped each resolved entity into the one shared pageBuilder.entityContext, so the LAST one won. The per-property link was already in hand and ignored: ItemPropertyMapping.DataSource carries widget.xml's `dataSource=\"...\"` and GenerateDefJSON already emits it for every chart dependent", "file": "`mdl/executor/widget_engine.go` (`itemEntityContextFor`, `prebuiltEntities` in `buildObjectListItem`)", "insight": "The item twin of the widget-level per-datasource context (#1109). Look for the SECOND copy whenever a context fix lands at widget level — object-list items run the same pre-resolve/resolve shape with their own loop. The shipped chart defs already map staticDataSource AND dynamicDataSource with every dependent's link, so nothing needed mapping; the links simply were not read. Note the weak in-repo signals: `mxcli check` only warns (MDL-WIDGET10, the inactive set is hidden) and the describe output looks right, so the defect is visible only in the stored BSON or from mxbuild. Charts' static/dynamic sit INSIDE the `lines` object list, not at widget level — a recursive widget.xml scan makes them look like widget properties", "ce": ["CE1613"]} {"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY MICROFLOW` re-enables concurrent execution on a microflow that disallowed it \u2014 the running app's concurrency protection removed \u2014 and drops the concurrency error message (all translations) and error microflow, plus `MarkAsUsed`. Every checker is green: **CE4899 fires only on disallow-without-a-message, never on allow**, so the one error that exists in this area is exactly the one the reset switches off", "cause": "`buildMicroflowFromStmt` built the rebuild struct with `AllowConcurrentExecution: true` and `MarkAsUsed: false` literals, and `microflowToGen` wrote `SetConcurrencyErrorMicroflowQualifiedName(\"\")` + a bare `genTexts.NewText()`. The backend already READ the two flags back (the #723 \u00a7A fix), so the round-trip test passed while the bug was live \u2014 the executor overwrote them before the backend ever saw them", "file": "`mdl/executor/cmd_microflows_build.go` (buildMicroflowFromStmt), `mdl/backend/modelsdk/microflow_write.go` (microflowToGen), `mdl/backend/modelsdk/microflow.go` (microflowFromGen), `sdk/microflows/microflows.go`", "fix": "Carry all four from the stored microflow, seeding the locals with the NEW-microflow defaults (true/false) so no separate preserve flag is needed. The error message reuses the existing `textFromGen`/`textToGen` pair, so translations survive; nil still emits the bare empty `Texts$Text` the writer always wrote", "insight": "**A passing round-trip test at one layer says nothing about the layer above it.** `TestMicroflowRoundTrip_ConcurrentExecutionFlags` had guarded these two flags since #723 and was green throughout, because the executor's rebuild struct overwrites them before calling the backend. When a property is reset, locate the LAST writer on the path, not the first one that looks responsible. **And check which way a reset goes**: #723's backend bug wrote the Go zero value (allow -> disallow) and hit CE4899 immediately; the executor's literal writes the opposite (disallow -> allow), and the same CE4899 that caught the first direction is structurally blind to the second. A checker that catches a property's loss in one direction is not coverage for that property. Two methodological traps in the test itself, both hit: `bytes.Equal` on two encodes of the same microflow ALWAYS differs (fresh random sub-element `$ID`s \u2014 the reason `canon` exists), and `canon.Equal` on a whole microflow always differs too, because `StableId` is a fresh GUID *value* per encode and `Equal` does not mask \u2014 only `Reconcile` may be asked that question. Compare the sub-element under test, or use Reconcile. Controls: hardcoding the executor literals back, emptying the writer's pair, and stubbing the reader each fail a different test with the reported symptom"} -{"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY EXTERNAL ENTITIES FROM` imports an OData entity with **none** of its ComplexType properties — `describe entity` lists only the key. `exec` reports `1 created, 0 failed` and prints nothing; `mx check` says 0 errors. The loss surfaces much later as CE1613 on a page written against the attributes Studio Pro would have made. `DESCRIBE CONTRACT ENTITY` compounded it by reporting the complex property as `String(200)`", "cause": "`mdl/types/edmx.go` never parsed `` at all, so a property typed `Shared.Uom.Quantity` was indistinguishable from one of an unknown type, and `createExternalEntities`' `if !strings.HasPrefix(p.Type, \"Edm.\")` dropped it with no `continue` message. `String(200)` was `edmToMendixType`'s default branch", "file": "`mdl/types/edmx.go` (EdmComplexType, FindComplexType, FlattenProperties, EdmProperty.RemotePath/Path), `mdl/executor/cmd_contract.go` (createExternalEntities, describeContractEntity, outputContractEntityMDL)", "insight": "**The local name and the remote name differ by SEPARATOR, and that is the whole fix.** Studio Pro names the attribute `MaxQty_UoMNId` and reads it over the OData path `MaxQty/UoMNId`; assuming RemoteName == attribute name is the obvious wrong turn and it is silent in the model. Measured on mxbuild 11.12.1, three copies of one project: RemoteName `MaxQty/UoMNId` -> 0 errors; `MaxQty_UoMNId` -> 4x **CE6615** \"Attribute 'X' of external entity 'Definition' does not exist in the OData service\"; a deliberately bogus path -> the same 4x CE6615. So mxbuild resolves the path INTO the complex type and genuinely validates it — the 0-error run is evidence, not a rubber stamp, and CE6615 is the detector to reach for on any external-entity remote-name question. Two more measurements worth not repeating: (1) the pre-fix control — attributes simply absent — is **0 errors**, so the build never catches the drop itself, only a later reference does; a regression test asserting `mx check` clean would have passed against the bug. (2) A flattened attribute is Creatable=False AND Updatable=False *whatever the entity set says*: against a contract annotated `Insertable=true`+`Updatable=true`, following the entity set costs 2x **CE6630** per attribute, matching Mendix's doc that entities with complex attributes 'can only be read or deleted'. Resolve complex types by QUALIFIED name — one document may declare `Quantity` in two namespaces, and FindEntityType's short-name fallback would silently hand over the other schema's properties. The report said 'only cross-namespace'; in fact every complex type was dropped, since nothing named `Edm.*` is complex — the reporter's service just happened to declare them elsewhere. Repro `mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl`", "file_refs": ["mdl/types/edmx.go", "mdl/executor/cmd_contract.go"], "refs": ["mendixlabs/mxcli#1118"], "ce": ["CE6615", "CE6630", "CE1613"]} +{"area": "mdl/executor", "date": "2026-09-17", "symptom": "`CREATE OR MODIFY EXTERNAL ENTITIES FROM` imports an OData entity with **none** of its ComplexType properties — `describe entity` lists only the key. `exec` reports `1 created, 0 failed` and prints nothing; `mx check` says 0 errors. The loss surfaces much later as CE1613 on a page written against the attributes Studio Pro would have made. `DESCRIBE CONTRACT ENTITY` compounded it by reporting the complex property as `String(200)`", "cause": "`mdl/types/edmx.go` never parsed `` at all, so a property typed `Shared.Uom.Quantity` was indistinguishable from one of an unknown type, and `createExternalEntities`' `if !strings.HasPrefix(p.Type, \"Edm.\")` dropped it with no `continue` message. `String(200)` was `edmToMendixType`'s default branch", "file": "`mdl/types/edmx.go` (EdmComplexType, FindComplexType, FlattenProperties, EdmProperty.RemotePath/Path), `mdl/executor/cmd_contract.go` (createExternalEntities, describeContractEntity, outputContractEntityMDL)", "insight": "**The local name and the remote name differ by SEPARATOR, and that is the core of the fix.** Studio Pro names the attribute `MaxQty_UoMNId` and reads it over the OData path `MaxQty/UoMNId`; assuming RemoteName == attribute name is the obvious wrong turn and it is silent in the model. Measured on mxbuild 11.12.1, three copies of one project: RemoteName `MaxQty/UoMNId` -> 0 errors; `MaxQty_UoMNId` -> 4x **CE6615** \"Attribute 'X' of external entity 'Definition' does not exist in the OData service\"; a deliberately bogus path -> the same 4x CE6615. So mxbuild resolves the path INTO the complex type and genuinely validates it — the 0-error run is evidence, not a rubber stamp, and CE6615 is the detector to reach for on any external-entity remote-name question. **Do not stop at a synthetic fixture.** A two-property complex type in its own namespace passed `mx check` clean and the fix still shipped four defects, all caught by the integration suite's live **TripPin** contract (`10-odata-examples.mdl`) at 11 errors. TripPin is the fixture to reach for: it has a base complex type, two types derived from it, a nested complex property, an Edm.GeographyPoint, and both a top-level entity set and types derived from it. What it taught, each measured: (1) Mendix imports a complex type's **own** properties only — flattening `AirportLocation`'s inherited `Address` is CE6615, while the same `Address` via `Person.HomeAddress` (typed `Location` directly) is accepted, so the line is inheritance, not path syntax; (2) `!strings.HasPrefix(t, \"Edm.\")` is not the supported-type test — `Edm.GeographyPoint` passes it and is **CE6622** \"The type of attribute 'Location_Loc' … is not supported\", so the importable primitives must be a closed set; (3) Creatable/Updatable are always false on a flattened attribute (against a contract annotated Insertable=true AND Updatable=true, Mendix still says False — 2x **CE6630** per attribute, matching the doc's \"can only be read or deleted\"), but **Filterable/Sortable are not**: they follow the ENTITY SET, and CE6630 fires in BOTH directions, so neither blanket answer survives. On TripPin, `Person` (entity set `People`) wants True and `Employee`/`Manager`/`Event` (derived, no entity set) want False; `Manager.BossOffice` is Manager's own property and still False, which rules out inheritance as the explanation. A test for a two-directional rule needs **both** controls — stamping false everywhere passes the derived case and fails People. Resolve complex types by QUALIFIED name: one document may declare `Quantity` in two namespaces, and FindEntityType's short-name fallback would silently hand over the other schema's properties. Also: the pre-fix control (attributes simply absent) is **0 errors**, so the build never catches the drop itself — a regression test asserting `mx check` clean would have passed against the bug. The report said 'only cross-namespace'; in fact every complex type was dropped, since nothing named `Edm.*` is complex. Repro `mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl`", "file_refs": ["mdl/types/edmx.go", "mdl/executor/cmd_contract.go"], "refs": ["mendixlabs/mxcli#1118"], "ce": ["CE6615", "CE6622", "CE6630", "CE1613"]} diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index 1be472fb23..f2f05a14e3 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -58,16 +58,26 @@ does — named `_` and read over the OData path `/ 0 errors, which is why -- the loss was invisible until something referenced them. -- +-- Three things a complex type carries that are NOT importable — each measured +-- against the live TripPin contract in doctype-tests/10-odata-examples.mdl, and +-- each now reported by name rather than dropped: +-- +-- inherited properties CE6615 (AirportLocation derives from Location; +-- its inherited Address is unreachable, +-- while Person.HomeAddress/Address is not) +-- Edm.GeographyPoint etc. CE6622 "type ... is not supported" +-- complex within complex (flattening is one level deep) +-- +-- And two capability rules: a flattened attribute is never creatable/updatable, +-- and is filterable/sortable only where its entity has an entity set — CE6630 +-- fires in BOTH directions, so neither blanket answer works. +-- -- Run it: -- cp mdl-examples/odata-local-metadata/complextype-metadata.xml /path/to/app/ -- mxcli exec 1118-odata-complextype-flattening.mdl -p app.mpr diff --git a/mdl/executor/cmd_contract.go b/mdl/executor/cmd_contract.go index 7bbc5e1aaf..5347f45c67 100644 --- a/mdl/executor/cmd_contract.go +++ b/mdl/executor/cmd_contract.go @@ -586,7 +586,7 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) // have made (mendixlabs/mxcli#1118). flatProps, nestedComplex := doc.FlattenProperties(mergedProps) for _, u := range nestedComplex { - dropped = append(dropped, fmt.Sprintf("%s.%s — complex type nested in a complex type; Mendix imports one level only", mendixName, u)) + dropped = append(dropped, fmt.Sprintf("%s.%s", mendixName, u)) } keyPropSet := make(map[string]bool) @@ -695,21 +695,46 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) if nonUpdatable[remoteName] || p.Computed || p.Immutable { updatable = false } - // A property reached through a complex type is READ-ONLY whatever - // the entity set's Insert/Update restrictions say: "External - // entities that contain attributes of complex types can only be - // read or deleted. They cannot be created, updated, or used in - // external actions" (Consumed OData Service Requirements). + // A property reached through a complex type carries NONE of the + // four capabilities, whatever the entity set says. // - // Measured on 11.12.1 against a contract annotated - // Insertable=true AND Updatable=true: Mendix still reports the - // flattened attributes as Creatable=False / Updatable=False, so - // following the entity set instead is two CE6630 per attribute - // ("'MaxQty_UoMNId' is marked Creatable=False in the OData - // service, but True in the app"). + // Creatable/Updatable follow "External entities that contain + // attributes of complex types can only be read or deleted. They + // cannot be created, updated, or used in external actions" + // (Consumed OData Service Requirements), measured on 11.12.1 + // against a contract annotated Insertable=true AND + // Updatable=true: Mendix still reports them Creatable=False / + // Updatable=False, so following the entity set is two CE6630 per + // attribute. + // + // Filterable/Sortable go the same way, but ONLY on an entity + // with no entity set. A flattened attribute is queryable exactly + // where its entity is: measured on TripPin (11.12.1), which + // carries no capability annotations at all, Mendix reports + // + // Person (entity set People) HomeAddress_Address True + // Employee (derived, no entity set) HomeAddress_Address False + // Manager (derived, no entity set) BossOffice_Address False + // Event (derived, no entity set) OccursAt_BuildingInfo False + // + // so CE6630 fires in BOTH directions and a blanket answer cannot + // be right: stamping false everywhere is "marked Filterable=True + // in the OData service, but False in the app" on People, and + // leaving the default true is the same sentence inverted on the + // other three. Manager.BossOffice is Manager's OWN property, so + // the split is the entity set, not inheritance. + // + // Ordinary attributes of a derived type stay filterable — this + // is a property of the flattening, not of derived types. + filterable := entitySet.AttrFilterable(remoteName) + sortable := entitySet.AttrSortable(remoteName) if p.RemotePath != "" { creatable = false updatable = false + if !isTopLevel { + filterable = false + sortable = false + } } attrName := attrNameForOData(p.Name, et.Name) @@ -724,8 +749,8 @@ func createExternalEntities(ctx *ExecContext, s *ast.CreateExternalEntitiesStmt) Type: edmToDomainModelAttrType(p, keyPropSet[p.Name]), RemoteName: remoteName, RemoteType: p.Type, - Filterable: entitySet.AttrFilterable(remoteName), - Sortable: entitySet.AttrSortable(remoteName), + Filterable: filterable, + Sortable: sortable, Creatable: creatable, Updatable: updatable, } diff --git a/mdl/executor/cmd_contract_complextype_test.go b/mdl/executor/cmd_contract_complextype_test.go index c1e8da7cda..4a07819dea 100644 --- a/mdl/executor/cmd_contract_complextype_test.go +++ b/mdl/executor/cmd_contract_complextype_test.go @@ -48,7 +48,7 @@ const complexTypeMetadata = ` // importComplexTypeContract runs CREATE OR MODIFY EXTERNAL ENTITIES FROM over // the metadata above and returns the entity that reached the backend plus the // executor's output. -func importComplexTypeContract(t *testing.T, metadata string) (*domainmodel.Entity, string) { +func importComplexTypeContract(t *testing.T, metadata string) (map[string]*domainmodel.Entity, string) { t.Helper() mod := mkModule("CustomModule") svc := &model.ConsumedODataService{ @@ -65,7 +65,7 @@ func importComplexTypeContract(t *testing.T, metadata string) (*domainmodel.Enti dm := &domainmodel.DomainModel{} dm.ID = nextID("dm") - var created *domainmodel.Entity + created := map[string]*domainmodel.Entity{} mb := &mock.MockBackend{ IsConnectedFunc: func() bool { return true }, @@ -75,7 +75,7 @@ func importComplexTypeContract(t *testing.T, metadata string) (*domainmodel.Enti }, GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, CreateEntityFunc: func(_ model.ID, e *domainmodel.Entity) error { - created = e + created[e.Name] = e dm.Entities = append(dm.Entities, e) return nil }, @@ -88,16 +88,38 @@ func importComplexTypeContract(t *testing.T, metadata string) (*domainmodel.Enti stmt := &ast.CreateExternalEntitiesStmt{ ServiceRef: ast.QualifiedName{Module: "CustomModule", Name: "App"}, TargetModule: "CustomModule", - EntityNames: []string{"Definition"}, CreateOrModify: true, } assertNoError(t, createExternalEntities(ctx, stmt)) - if created == nil { + if len(created) == 0 { t.Fatal("no entity was created") } return created, buf.String() } +// importOne is the single-entity form most tests want. +func importOne(t *testing.T, metadata, entity string) (*domainmodel.Entity, string) { + t.Helper() + all, out := importComplexTypeContract(t, metadata) + ent := all[entity] + if ent == nil { + names := make([]string, 0, len(all)) + for n := range all { + names = append(names, n) + } + t.Fatalf("entity %q not created; got %v", entity, names) + } + return ent, out +} + +func attrByName(e *domainmodel.Entity) map[string]*domainmodel.Attribute { + m := map[string]*domainmodel.Attribute{} + for _, a := range e.Attributes { + m[a.Name] = a + } + return m +} + func attrNames(e *domainmodel.Entity) []string { var out []string for _, a := range e.Attributes { @@ -108,7 +130,7 @@ func attrNames(e *domainmodel.Entity) []string { // The reported symptom: "The imported entity contains none of these attributes." func TestCreateExternalEntities_FlattensCrossNamespaceComplexTypes(t *testing.T) { - ent, _ := importComplexTypeContract(t, complexTypeMetadata) + ent, _ := importOne(t, complexTypeMetadata, "Definition") want := []string{ "MaxQty_UoMNId", "MaxQty_QuantityValue", @@ -142,7 +164,7 @@ func TestCreateExternalEntities_FlattensCrossNamespaceComplexTypes(t *testing.T) // property's. A Decimal flattened as String is a silent data-type change that // only shows up when an expression fails to compile. func TestCreateExternalEntities_FlattenedAttributeKeepsLeafType(t *testing.T) { - ent, _ := importComplexTypeContract(t, complexTypeMetadata) + ent, _ := importOne(t, complexTypeMetadata, "Definition") byName := map[string]*domainmodel.Attribute{} for _, a := range ent.Attributes { byName[a.Name] = a @@ -190,7 +212,7 @@ func TestCreateExternalEntities_ReportsPropertiesItCannotMap(t *testing.T) { ` - _, out := importComplexTypeContract(t, danglingComplex) + _, out := importOne(t, danglingComplex, "Definition") if !strings.Contains(out, "MaxQty") { t.Errorf("the dropped property is not named in the output — this is the silence in mendixlabs/mxcli#1118:\n%s", out) } @@ -234,7 +256,7 @@ func TestCreateExternalEntities_FlattenedAttributesAreReadOnly(t *testing.T) { ` - ent, _ := importComplexTypeContract(t, writableContract) + ent, _ := importOne(t, writableContract, "Definition") byName := map[string]*domainmodel.Attribute{} for _, a := range ent.Attributes { byName[a.Name] = a @@ -275,7 +297,7 @@ func TestCreateExternalEntities_FlattenedAttributesAreReadOnly(t *testing.T) { // the 0-error run is evidence, not a rubber stamp. Local name and remote name // differ by separator here, which is the detail that looks like a typo in a diff. func TestCreateExternalEntities_FlattenedRemoteNameIsTheODataPath(t *testing.T) { - ent, _ := importComplexTypeContract(t, complexTypeMetadata) + ent, _ := importOne(t, complexTypeMetadata, "Definition") for _, a := range ent.Attributes { if a.Name == "MaxQty_UoMNId" { if a.RemoteName != "MaxQty/UoMNId" { @@ -286,3 +308,140 @@ func TestCreateExternalEntities_FlattenedRemoteNameIsTheODataPath(t *testing.T) } t.Fatal("MaxQty_UoMNId missing") } + +// trippinShapedContract reproduces the three things the synthetic fixture above +// does not have, all of them present in the public TripPin service: +// +// Location — a base complex type, with a nested complex property (City) +// AirportLocation — derived from it, adding an Edm.GeographyPoint +// Person/Employee — a top-level entity set and a type derived from it +const trippinShapedContract = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +// Mendix imports a complex type's OWN properties only — an inherited one is not +// addressable through the derived type. +// +// Measured on mxbuild 11.12.1 against TripPin: flattening `AirportLocation`'s +// inherited `Address` gives +// +// CE6615 "Attribute 'Location_Address' of external entity 'Airports' does not +// exist in the OData service." +// +// while the SAME `Address` reached through `Person.HomeAddress`, typed `Location` +// directly, is accepted — so the line is inheritance, not the path syntax. +func TestCreateExternalEntities_DoesNotFlattenInheritedComplexProperties(t *testing.T) { + airport, out := importOne(t, trippinShapedContract, "Airports") + got := attrByName(airport) + + if _, ok := got["Location_Address"]; ok { + t.Error("Location_Address was imported — it is inherited from Trippin.Location, which is CE6615") + } + // …and it is named rather than dropped in silence. + if !strings.Contains(out, "Location/Address") { + t.Errorf("the inherited property is not reported:\n%s", out) + } + + // The control: the SAME leaf name reached through a non-derived complex type + // must still be imported. Without this the test passes against a build that + // stopped flattening altogether. + person, _ := importOne(t, trippinShapedContract, "People") + if _, ok := attrByName(person)["HomeAddress_Address"]; !ok { + t.Error("HomeAddress_Address is missing — Location is not derived, so it must flatten") + } +} + +// A leaf whose type Mendix cannot represent is not an attribute. Measured on +// 11.12.1: importing TripPin's `AirportLocation.Loc` (Edm.GeographyPoint) is +// +// CE6622 "The type of attribute 'Location_Loc' in the OData service is not +// supported. Please delete this attribute." +// +// `!strings.HasPrefix(t, "Edm.")` is not enough — GeographyPoint passes it. +func TestCreateExternalEntities_SkipsUnsupportedLeafTypes(t *testing.T) { + airport, out := importOne(t, trippinShapedContract, "Airports") + if _, ok := attrByName(airport)["Location_Loc"]; ok { + t.Error("Location_Loc was imported as an attribute — Edm.GeographyPoint is CE6622") + } + if !strings.Contains(out, "Location/Loc") || !strings.Contains(out, "Edm.GeographyPoint") { + t.Errorf("the unsupported leaf is not reported with its type:\n%s", out) + } + + // A nested complex type is refused the same way, and named as such. + if !strings.Contains(out, "HomeAddress/City") { + t.Errorf("the nested complex property is not reported:\n%s", out) + } +} + +// A flattened attribute is queryable exactly where its entity is. CE6630 fires +// in BOTH directions, so neither blanket answer is right — measured on TripPin +// (11.12.1), which carries no capability annotations at all: +// +// Person (entity set People) HomeAddress_Address Filterable True +// Employee (derived, no entity set) HomeAddress_Address Filterable False +func TestCreateExternalEntities_FlattenedFilterabilityFollowsTheEntitySet(t *testing.T) { + person, _ := importOne(t, trippinShapedContract, "People") + top := attrByName(person)["HomeAddress_Address"] + if top == nil { + t.Fatal("People.HomeAddress_Address missing") + } + if !top.Filterable || !top.Sortable { + t.Errorf("People.HomeAddress_Address Filterable=%v Sortable=%v, want both true — "+ + "the set is queryable and the contract restricts nothing (CE6630 in the other direction)", + top.Filterable, top.Sortable) + } + + employee, _ := importOne(t, trippinShapedContract, "Employee") + derived := attrByName(employee) + flat := derived["HomeAddress_Address"] + if flat == nil { + t.Fatal("Employee.HomeAddress_Address missing") + } + if flat.Filterable || flat.Sortable { + t.Errorf("Employee.HomeAddress_Address Filterable=%v Sortable=%v, want both false — CE6630", + flat.Filterable, flat.Sortable) + } + + // The control: this is a property of the FLATTENING, not of derived entity + // types. An ordinary attribute of the same derived entity stays filterable, + // which is what it was before any of this and what mx check accepts. + plain := derived["Cost"] + if plain == nil { + t.Fatal("Employee.Cost missing") + } + if !plain.Filterable || !plain.Sortable { + t.Errorf("Employee.Cost Filterable=%v Sortable=%v, want both true — "+ + "derived types are not themselves unfilterable", plain.Filterable, plain.Sortable) + } +} diff --git a/mdl/types/edmx.go b/mdl/types/edmx.go index b20aeed31c..0c1acf6ca1 100644 --- a/mdl/types/edmx.go +++ b/mdl/types/edmx.go @@ -466,11 +466,25 @@ func (d *EdmxDocument) FindComplexType(qualifiedName string) *EdmComplexType { return nil } -// complexTypeProperties returns a complex type's own properties preceded by -// those it inherits, walking the BaseType chain. The depth guard is for a -// document whose base types form a cycle — malformed, but it arrives over the -// network and must not hang the import. -func (d *EdmxDocument) complexTypeProperties(ct *EdmComplexType) []*EdmProperty { +// inheritedComplexProperties returns the properties a complex type inherits +// from its BaseType chain — the ones that are NOT importable, so that a caller +// can name them instead of dropping them in silence. +// +// Mendix imports a complex type's OWN properties only. Measured on mxbuild +// 11.12.1 against TripPin, whose `AirportLocation` and `EventLocation` both +// derive from `Location`: flattening the inherited `Address` gives +// +// CE6615 "Attribute 'Location_Address' of external entity 'Airports' does +// not exist in the OData service." +// +// while the same `Address` reached through `Person.HomeAddress`, typed +// `Location` directly, is recognised — and each derived type's OWN property +// (`Loc`, `BuildingInfo`) is recognised too. So the line is inheritance, not +// the path syntax. +// +// The depth guard is for a document whose base types form a cycle — malformed, +// but it arrives over the network and must not hang the import. +func (d *EdmxDocument) inheritedComplexProperties(ct *EdmComplexType) []*EdmProperty { var props []*EdmProperty seen := map[*EdmComplexType]bool{} var walk func(*EdmComplexType) @@ -479,15 +493,34 @@ func (d *EdmxDocument) complexTypeProperties(ct *EdmComplexType) []*EdmProperty return } seen[c] = true - if c.BaseType != "" { - walk(d.FindComplexType(c.BaseType)) - } props = append(props, c.Properties...) + walk(d.FindComplexType(c.BaseType)) + } + if ct != nil && ct.BaseType != "" { + walk(d.FindComplexType(ct.BaseType)) } - walk(ct) return props } +// importableEdmTypes are the primitive types Mendix maps to an attribute — +// Consumed OData Service Requirements' "Supported Attribute Types", which is +// also the list its complex-type paragraph defers to. +// +// It has to be a closed set, not `strings.HasPrefix(t, "Edm.")`: measured on +// 11.12.1, flattening TripPin's `AirportLocation.Loc` (Edm.GeographyPoint) is +// +// CE6622 "The type of attribute 'Location_Loc' in the OData service is not +// supported. Please delete this attribute." +// +// Edm.Duration is absent deliberately — Mendix has no duration type, and the +// import has always skipped it. +var importableEdmTypes = map[string]bool{ + "Edm.String": true, "Edm.Boolean": true, "Edm.Guid": true, "Edm.Binary": true, + "Edm.Byte": true, "Edm.SByte": true, "Edm.Int16": true, "Edm.Int32": true, "Edm.Int64": true, + "Edm.Decimal": true, "Edm.Double": true, "Edm.Single": true, + "Edm.Date": true, "Edm.DateTime": true, "Edm.DateTimeOffset": true, +} + // FlattenProperties expands every complex-typed property into one property per // leaf, and passes everything else through untouched. // @@ -502,13 +535,19 @@ func (d *EdmxDocument) complexTypeProperties(ct *EdmComplexType) []*EdmProperty // `MaxQty_QuantityValue`, addressed over the paths `MaxQty/UoMNId` and // `MaxQty/QuantityValue`. // -// Flattening is ONE level deep, matching the same page's "only the properties of -// the types described in Supported Attribute Types are supported" — that list is -// the primitive Edm types, so a complex type nested in a complex type is not -// importable. It is returned in unsupported rather than dropped, because a -// caller that says nothing is the defect this whole function exists to fix -// (mendixlabs/mxcli#1118). A property whose type is not a complex type this document -// declares is passed through unchanged for the caller's own rules to judge. +// Three things are NOT importable, and each is returned in unsupported rather +// than dropped — a caller that says nothing is the defect this whole function +// exists to fix (mendixlabs/mxcli#1118): +// +// - a leaf whose type is not in importableEdmTypes, which includes a complex +// type nested in a complex type (flattening is one level deep, matching the +// same page's "only the properties of the types described in Supported +// Attribute Types are supported"); +// - a property INHERITED from the complex type's BaseType — see +// inheritedComplexProperties for the measurement. +// +// A property whose type is not a complex type this document declares is passed +// through unchanged for the caller's own rules to judge. func (d *EdmxDocument) FlattenProperties(props []*EdmProperty) (flat []*EdmProperty, unsupported []string) { for _, p := range props { ct := d.FindComplexType(p.Type) @@ -516,10 +555,14 @@ func (d *EdmxDocument) FlattenProperties(props []*EdmProperty) (flat []*EdmPrope flat = append(flat, p) continue } - for _, leaf := range d.complexTypeProperties(ct) { + for _, leaf := range ct.Properties { path := p.Name + "/" + leaf.Name - if !strings.HasPrefix(leaf.Type, "Edm.") { - unsupported = append(unsupported, fmt.Sprintf("%s (%s)", path, leaf.Type)) + if !importableEdmTypes[leaf.Type] { + reason := leaf.Type + if d.FindComplexType(leaf.Type) != nil { + reason = leaf.Type + ", a complex type nested in a complex type" + } + unsupported = append(unsupported, fmt.Sprintf("%s (%s)", path, reason)) continue } expanded := *leaf @@ -532,6 +575,13 @@ func (d *EdmxDocument) FlattenProperties(props []*EdmProperty) (flat []*EdmPrope expanded.Immutable = expanded.Immutable || p.Immutable flat = append(flat, &expanded) } + // Inherited properties are not importable — name them rather than let + // them disappear, since a reader looking at the contract will expect + // them and they are exactly what CE6615 fires on. + for _, leaf := range d.inheritedComplexProperties(ct) { + unsupported = append(unsupported, fmt.Sprintf("%s/%s (inherited from %s; Mendix imports a complex type's own properties only)", + p.Name, leaf.Name, ct.BaseType)) + } } return flat, unsupported }