diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index fdf81448e0..02a047ee96 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -637,3 +637,5 @@ {"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":[]} +{"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/.claude/skills/mendix/bootstrap-app/SKILL.md b/.claude/skills/mendix/bootstrap-app/SKILL.md index e6b69f65ae..cc423375ee 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,38 @@ 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 … )` — 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 +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/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/cmd/mxcli/init_claudemd.go b/cmd/mxcli/init_claudemd.go index 7d53060b9b..e259705060 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. " + 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/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/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/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{ 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", diff --git a/docs-site/src/reference/odata/README.md b/docs-site/src/reference/odata/README.md index 20a683ccdc..f2f05a14e3 100644 --- a/docs-site/src/reference/odata/README.md +++ b/docs-site/src/reference/odata/README.md @@ -45,6 +45,40 @@ 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`. + +Three properties of a complex type are **not** importable. Each is reported by +name and reason rather than dropped, so an import that loses something says so: + +- **Inherited properties.** Only a complex type's own properties are imported. + Where `AirportLocation` derives from `Location`, the inherited `Address` is not + reachable through it (`CE6615`) — though the same `Address` is imported + normally through a property typed `Location` directly. +- **Leaves Mendix cannot represent**, such as `Edm.GeographyPoint` (`CE6622`). +- **A complex type nested in a complex type** — flattening is one level deep. + +Two more consequences: + +- **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`. +- **They are filterable and sortable only where their entity is** — that is, on + an entity backed by an entity set. On a derived or contained type they are + neither. `CE6630` fires in both directions here, so this follows the contract + rather than a fixed answer. + ## Contract Browsing Statements Browse available assets from cached service contracts without network access. 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 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..6bdee0d07d --- /dev/null +++ b/mdl-examples/bug-tests/1118-odata-complextype-flattening.mdl @@ -0,0 +1,69 @@ +-- 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. +-- +-- 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 + +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/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-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/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 ce90d6a4d6..a9b13d1107 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" @@ -285,6 +284,7 @@ func (b *Builder) buildEnumerations() error { projectID, snapshotID := b.snapshotMeta() valueCount := 0 + lang := b.defaultLanguage() for _, enum := range enums { // Get module name using hierarchy @@ -321,24 +321,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_contract.go b/mdl/executor/cmd_contract.go index d66fc3dde0..5347f45c67 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", mendixName, u)) + } + keyPropSet := make(map[string]bool) for _, k := range keyProps { keyPropSet[k] = true @@ -626,32 +658,83 @@ 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 carries NONE of the + // four capabilities, whatever the entity set says. + // + // 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) @@ -664,10 +747,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: filterable, + Sortable: sortable, Creatable: creatable, Updatable: updatable, } @@ -744,6 +827,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..4a07819dea --- /dev/null +++ b/mdl/executor/cmd_contract_complextype_test.go @@ -0,0 +1,447 @@ +// 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) (map[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") + + created := map[string]*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.Name] = 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", + CreateOrModify: true, + } + assertNoError(t, createExternalEntities(ctx, stmt)) + 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 { + 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, _ := importOne(t, complexTypeMetadata, "Definition") + + 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, _ := importOne(t, complexTypeMetadata, "Definition") + 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 := 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) + } +} + +// 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, _ := importOne(t, writableContract, "Definition") + 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, _ := importOne(t, complexTypeMetadata, "Definition") + 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") +} + +// 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/executor/cmd_diff_mdl.go b/mdl/executor/cmd_diff_mdl.go index c95acfc7b6..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)) } } } @@ -363,15 +358,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_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.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..86f7967ee1 --- /dev/null +++ b/mdl/executor/cmd_enumerations_caption_language_test.go @@ -0,0 +1,191 @@ +// 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" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// 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") + } +} + +// 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)) } 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 } } diff --git a/mdl/types/edmx.go b/mdl/types/edmx.go index e57f526e36..0c1acf6ca1 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,194 @@ 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 +} + +// 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) + walk = func(c *EdmComplexType) { + if c == nil || seen[c] { + return + } + seen[c] = true + props = append(props, c.Properties...) + walk(d.FindComplexType(c.BaseType)) + } + if ct != nil && ct.BaseType != "" { + walk(d.FindComplexType(ct.BaseType)) + } + 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. +// +// 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`. +// +// 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) + if ct == nil { + flat = append(flat, p) + continue + } + for _, leaf := range ct.Properties { + path := p.Name + "/" + leaf.Name + 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 + 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) + } + // 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 +} + // 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 +725,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 +753,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) + } +}