diff --git a/.claude/skills/fix-issue/findings/mdl-executor.jsonl b/.claude/skills/fix-issue/findings/mdl-executor.jsonl index 1319382a8..aa40755e6 100644 --- a/.claude/skills/fix-issue/findings/mdl-executor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-executor.jsonl @@ -686,3 +686,7 @@ {"area": "mdl/executor", "date": "2026-09-23", "symptom": "`CALL MICROFLOW M.F(…) IN QUEUE M.Q` where `F` returns **Boolean**: `mxcli check -p --references` says `Check passed!`, `mx check` says **CE7033** \"A microflow used for background execution must have a Microflow return type of 'Nothing'.\" (at Call microflow activity 'F'). Reported with the CE0142 after-startup sibling, which MDL073 had already closed.", "cause": "The --references pass resolved the call target and the queue name separately, and both resolve. Nothing compared the binding (`in queue`) against the signature of the flow it names. Added MDL088: a project-less pass (ValidateQueuedCallReturnType) for a target the script creates, and validateQueuedMicroflowTargets on the --references path for a stored target, which skips script-defined targets so the fault is not printed twice. Stored void microflows read back as ReturnType \"Void\", not \"\" — both must mean Nothing.", "file": "`mdl/executor/validate_queued_call_return.go` (queuedMicroflowCalls, checkQueuedMicroflowReturnsNothing, validateQueuedMicroflowTargets, ValidateQueuedCallReturnType), wired in `validate_program.go` and `validate.go` (validateFlowBodyReferences); examples `mdl-examples/bug-tests/1064-queued-microflow-must-return-nothing{,.fail}.mdl`", "insight": "Same class as MDL073 (\"the reference resolves\" ≠ \"the reference is usable\"): any binding that names a flow carries a constraint on that flow's signature, and a resolver checks only the name. When one such check lands, sweep for its siblings at other binding sites. The queued CALL JAVA ACTION twin (CE7038) is still unchecked and was deliberately left out of scope. Two things that cost time: (1) `mxcli exec` of a script that CREATEs a queue and then binds a call to it refuses with 'task queue not found' — validateFlowBodyReferences checks queues against the project only, not the script context — so the repro has to create the queue in a separate exec; (2) walk call statements by reflection, not by the flowRefCollector switch, which does not descend into WHILE bodies. Measured on mxbuild 11.12.0 with two projects: Boolean target → CE7033, void target → 0 errors.", "refs": ["mendixlabs/mxcli#1064"], "ce": ["CE7033"], "rules": ["MDL088"]} {"area": "mdl/executor/microflow-layout", "date": "2026-09-23", "symptom": "MPR011 fires on EVERY `while` loop mxcli writes \u2014 'first activity at (50,80) lies outside the loop box' \u2014 single-level loops included. `mx check` passes and the app runs; the flow just renders wrong in Studio Pro. Reported from a real project as 'looks like an mxcli layout issue', with 3 MPR011 warnings still in its final lint run. mxcli's own lint rule was correctly flagging mxcli's own output.", "cause": "One missing term in the WHILE builder. addWhileStatement had `innerStartX := LoopPadding` (50) where addLoopStatement has `LoopPadding + iteratorSpace + ActivityWidth/2` (210). A microflow object's Position is its CENTRE \u2014 the builder says so itself ('Position is the CENTER point (RelativeMiddlePoint in Mendix)') \u2014 so a centre at x=50 with ActivityWidth=120 puts the left edge at -10. The doc comment says the while layout 'matches addLoopStatement but without iterator icon space': dropping the iterator space (100) was right, taking ActivityWidth/2 with it was not, because that term is not iterator space, it is what converts a centre to a left edge. The very next line, `innerStartY := LoopPadding + ActivityHeight/2`, adds the half-height for exactly this reason \u2014 so the omission was accidental, not a choice. Reported (50,80) matches term for term: 50 = LoopPadding, 80 = LoopPadding + ActivityHeight/2.", "file": "`mdl/executor/cmd_microflows_builder_control.go` (addWhileStatement: `innerStartX := LoopPadding + ActivityWidth/2`), tests `mdl/executor/loop_containment_test.go` (TestWhileLoopBox_ContainsDefaultLaidOutChildren, TestWhileLoopFirstChildLeftEdgeIsInsideTheBox)", "insight": "The containment invariant WAS already tested \u2014 loop_containment_test.go exists from #884 and asserts exactly this \u2014 but every fixture in it built a FOREACH loop. There are two loop builders; one was covered and the uncovered one shipped the violation into every project that writes a `while`. An invariant is worth what its COVERAGE is, and a file named for an invariant reads as if it covers the invariant, which is how a second code path goes unexamined for months. When a rule flags the tool's own output, believe the rule first: the reporter hedged with 'looks like an mxcli layout issue' and was exactly right. Cheap tell for this class: a term present on one axis and absent on the other in adjacent lines (`+ ActivityHeight/2` on Y, nothing on X) is almost always an omission rather than a decision. Failing test written first; it reproduced the reported geometry to the pixel, x[-10,...] at 1, 2, 4 and 7 activities. Still uncovered: addManualWhileTrueStatement, the third loop builder.", "refs": ["ako/mxcli#884", "ako/mxcli#645"]} {"date": "2026-09-23", "area": "mdl-executor", "symptom": "upstream #1176: DESCRIBE prints `all` on an import activity that returns ONE object — `$objectResponse = import from mapping M.IMM($s) all;` — which reads as a list import. Reported on v0.23.0 / Studio Pro 11.12.3, after #881 was believed to have settled import ranges", "cause": "#881 made `formatImportMappingRange` always emit a range keyword, because at the time a missing keyword let the range fall back to the variable's cardinality and store First. The later runtime fix (unauthored range written as All explicitly) made bare and `all` build the same activity, but the describe side was never revisited, so `all` kept printing where it was only noise", "file": "`mdl/executor/cmd_microflows_format_action.go` (`formatImportMappingRange`: return \"\" for All against SingleObject); tests `mdl/executor/cmd_microflows_import_range_test.go` (`TestImportRange_ObjectResultDescribesWithoutAll`); example `mdl-examples/bug-tests/1176-import-mapping-object-describes-without-all.mdl`", "insight": "**This was not #881 regressing — it was #881's own workaround outliving its reason.** 'DESCRIBE must never emit nothing' was a guard against the builder's then-broken default; once the builder wrote a missing keyword as All explicitly, the guard became pure noise, and nothing linked the two sites. When a formatter emits something 'because the builder would otherwise infer X', put that reason in a test that asserts the builder equivalence (bare vs keyword build the same activity), so fixing the builder flags the formatter. Proving the omission safe needs that equivalence on a real project, not just the unit test: on 11.12.3 both spellings store byte-identical ResultHandling (ConstantRange{SingleObject:false} + ObjectType), `mx check` 0 errors, and exec'ing the described text reports 'Unchanged microflow'. Wrong turn to skip: a JSON diff of two EMPTY extractions prints 'IDENTICAL' — `bson dump` emits ordered Key/Value lists, not objects; check the extraction is non-empty before trusting a diff", "refs": ["#881", "#1176"]} +{"area": "mdl-executor", "date": "2026-09-24", "refs": ["#1173"], "symptom": "`ALTER ENTITY ADD ATTRIBUTE Region: String(200)` passed `mxcli check -p --references`, exec printed \"Added attribute 'Region' to entity MyFirstModule.SaleStats\" with exit 0, and `mx check` then failed CE6770 \"View Entity is out of sync with the OQL Query.\" The attribute was written as DomainModels$StoredValue with no OQL column behind it", "cause": "execAlterEntity's ADD/DROP ATTRIBUTE branches treat every entity as a table: nothing asked isViewEntity, although CREATE ASSOCIATION and bulk ALTER ENTITIES already did. The check-time AlterEntityStmt case only resolved the module and enumerations", "file": "`mdl/executor/cmd_entities.go` (viewEntityAttributeSetRefusal, AlterEntityAddAttribute/DropAttribute guards), `mdl/executor/validate.go` (validateViewEntityAttributeSet); test `mdl/executor/alter_entity_view_test.go`; bug-test `mdl-examples/bug-tests/1173-alter-view-entity-attribute.mdl`", "insight": "**Measure every ALTER op on a view before choosing the fix, not just the reported one** — one mxbuild per op on 11.12.1: ADD → CE6770, DROP → CE6770, MODIFY to the wrong type → CE6770 but MODIFY to the matching type → 0 errors (so MODIFY's failure is a type mismatch, a different gap, not this one), RENAME → 0 errors (the OqlViewValue binds the column by its Reference/alias, not the attribute name), SET COMMENT → 0. Refusing all four attribute ALTERs would have blocked a working RENAME. **Refuse rather than bind**: the issue offers \"create an OqlViewValue bound to the matching alias\", but for ADD there is no matching alias — the query has no such column — so any write stays CE6770; the query is the declaration, so the refusal points at `create or modify view entity`, verified to build clean with the added column. Guard both layers: check must see a view the SCRIPT creates (sc.viewEntities) as well as a stored one (findEntity + isViewEntity), or check passes a script exec stops halfway. Bulk ALTER ENTITIES already excluded views (e.Source/OqlQuery), so the single-entity path was the only entry. Control: stubbing both guards fails all four refusal tests with \"accepted — mxbuild reports CE6770\""} +{"date": "2026-09-24", "area": "mdl-executor", "symptom": "upstream #1175: a `--` comment inside a view entity's select list produced false MDL030 — `select column 1 has no as alias: '-- the customer's running total'` plus a second one for the text after the comment's comma. Reported on v0.23.0 as an apostrophe bug", "cause": "Every static OQL check (ValidateOQLSyntax, ValidateOQLTypes, inferOQLTypes, viewAssociationColumns) works on `Query.RawQuery`, which is stored verbatim and so keeps the author's comments. parseSelectColumns splits on top-level commas with no notion of a comment, so the comment became a column and each comma in it another", "file": "`mdl/executor/oql_comments.go` (`stripOQLComments`, called at the top of the four entry points in `oql_type_inference.go` / `oql_view_associations.go`); tests `mdl/executor/validate_oql_comments_test.go`; example `mdl-examples/bug-tests/1175-oql-comment-is-not-a-select-column.mdl`", "insight": "**The apostrophe was a red herring: a comment with no apostrophe fails the same way** — measured before theorising, and it moved the fix from the quote-skipping in topLevelKeywordIndex to the comment itself. Strip at the entry points, not inside the helpers: the checks also run regexes over the whole query (division, association-path, reserved word), and a comment containing `from`, `/` or `a.B.C_D` would trip those too. Blank comments to spaces of the same length rather than deleting them, so any offset computed on the stripped text still indexes the original. Do NOT strip in the visitor — the stored query keeps the comments, which is the author's documentation. A type-check test using the same query passed without the fix (comment columns infer no type), so it was dropped rather than kept as a test that detects nothing", "refs": ["mendixlabs/mxcli#1175"], "rules": ["MDL030"]} +{"area":"mdl/executor","date":"2026-09-24","symptom":"`alter page M.P { insert into dv { use fragment SaveCancelFooter } }` passed `check` and failed in exec: \"failed to insert: failed to build widgets: failed to build widget SaveCancelFooter: unsupported widget type: USE_FRAGMENT\". Same for REPLACE, and for a fragment nested inside an inserted container.","cause":"Fragment/building-block expansion (pageBuilder.expandFragments) was called only on the CREATE PAGE / snippet / layout paths. ALTER PAGE's applyInsertWidgetMutator / applyReplaceWidgetMutator passed op.Widgets straight to buildWidgetsFromAST, whose pageBuilder even carried ctx.Fragments — the registry was wired, the expansion call was not. Second defect found on the way: cloneWidget copied only Type/Name/Properties/Children, dropping Specialization and TypeIsGeneric, so a cloned `template for` lost its routing.","file":"`mdl/executor/cmd_alter_page.go` (expandAlterFragments, called first in the INSERT and REPLACE mutators), `mdl/executor/cmd_pages_builder_v3.go` (cloneWidget copies the whole struct)","insight":"Expand before ANYTHING inspects the widget list, not just before the build: the duplicate-name check, allColumns and allListViewTemplates all read op.Widgets, and seeing the sentinel they check the fragment's name instead of its widgets' names. When a registry is threaded into a builder, grep for the call that consumes it (`expandFragments`), not for the field — the field being set on every pageBuilder is what made the ALTER path look covered. A field-by-field clone is a silent-drop hazard; `c := *w` then deep-copy the reference fields.","refs":["#572"]} +{"area":"mdl/executor","date":"2026-09-24","symptom":"`describe layout Atlas_Core.Phone_BottomBar` emitted `-- Forms$SimpleMenuBar (simpleMenuBar1) -- NOT re-executable: mxcli cannot author this widget, so re-running this script would drop it`, so a phone layout written in MDL had no bottom bar and a describe -> exec copy of Atlas's lost it","cause":"No keyword, builder, writer or describer for Forms$SimpleMenuBar, although modelsdk/gen already had SimpleMenuBar and MenuDocumentSource. And the widget's whole point is WHICH menu document it renders (a Forms$MenuDocumentSource in MenuSource), a source kind no menu widget could express — menubar/navigationtree always wrote a Forms$NavigationSource, so one pointed at a menu document described as `menubar m` and replayed onto the Responsive profile. Layouts were also never reference-checked, so a new `Menu:` typo would have passed --references and hit CE1613 at build","file":"mdl/grammar/MDLLexer.g4 + domains/MDLPage.g4 + domains/MDLSettings.g4 (SIMPLEMENUBAR), mdl/executor/cmd_pages_builder_v3.go (buildSimpleMenuBarV3, menuSourceV3), mdl/backend/modelsdk/widget_write.go (menuSourceToGen), mdl/executor/cmd_pages_describe_parse.go + cmd_pages_describe_output.go, mdl/executor/helpers.go + validate.go (menu refs, CreateLayoutStmt), sdk/pages/pages_widgets_advanced.go","insight":"**Measure the stored shape before designing the syntax** — dumping the widget from a blank project (`mxcli new --version 11.14.0`, `bson dump --type layout`) showed the source was a menu DOCUMENT, not a profile, which is what made this a source-kind feature rather than a one-keyword copy of `menubar`. Treat MenuSource as one slot with two subtypes on all three menu widgets (one helper each side), or the siblings keep silently rewriting a menu-document source to Responsive. The control that justified the reference check: `menu: Bug573.No_Such_Menu` -> `mxcli check --references` passed, `mx check` CE1613 'The selected menu ... no longer exists.' at Simple menu bar. MDL has no `show menus`, so the not-found message lists the menus that exist. Adding reference validation to CreateLayoutStmt is new coverage for EVERY widget reference in a layout, not just menus","refs":["ako/mxcli#573"],"ce":["CE1613"]} diff --git a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl index 26304cc9b..324517e98 100644 --- a/.claude/skills/fix-issue/findings/mdl-grammar.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-grammar.jsonl @@ -61,3 +61,4 @@ {"area": "mdl/grammar", "date": "2026-09-21", "symptom": "A new settings option list keyed on `IDENTIFIER` makes the feature's ONLY option a parse error: `alter settings workflows add group 'Approvers' (Description: '\u2026')` \u2192 \"mismatched input 'Description' expecting IDENTIFIER\"", "cause": "`Description` is an MDL lexer keyword (DESCRIPTION, from the security statements), so it never matches IDENTIFIER. The rule was copied from `languageOption`, whose keys (CheckCompleteness, CustomDateFormat\u2026) all happen to be plain identifiers \u2014 so the pattern looked safe and was not", "file": "`mdl/grammar/domains/MDLSettings.g4` (`settingsItemOption`) + `mdl/visitor/visitor_settings.go` (`collectSettingsItemOptions`)", "insight": "Any `( key: value )` option list must key on `identifierOrKeyword`, not IDENTIFIER, and the visitor must read it with `unquoteIdentifier(ctx.IdentifierOrKeyword().GetText())`. Before writing one, grep MDLLexer.g4 for each key you intend to accept \u2014 the check costs seconds and the failure lands on the single statement the feature exists for. Copying an existing option rule proves nothing about your key set. Control: reverting the rule to IDENTIFIER fails TestAlterSettings_WorkflowGroup with exactly that message. mendixlabs/mxcli#272", "refs": ["mendixlabs/mxcli#272"]} {"area": "mdl/grammar", "date": "2026-09-22", "symptom": "A `create workflow` clause written in the \"wrong\" position is a parse error — `on created microflow` anywhere but between the targeting clauses and `entity` gives `line 6:4 mismatched input 'ON' expecting ';'`, and a header clause out of place gives `mismatched input 'DISPLAY' expecting {ON, BEGIN, EXPORT, DUE, OVERVIEW}`. Neither names the clause or the rule, and one misplaced clause cascades into 3–7 more errors including a bogus `extraneous input 'END'`. The reporter reverse-engineered the order empirically and wrote it into their notes", "cause": "`createWorkflowStatement` and `workflowUserTaskStmt` were a fixed SEQUENCE of optional groups — each clause optional, its POSITION not — and the VISITOR depended on that: it read qualified names by COUNTING (`names[1]` or `names[2]` for the overview page depending on whether PARAMETER was present; `nameIdx` walked page → targeting → on-created → entity) and strings by index off `AllSTRING_LITERAL()`. So the grammar could not simply be relaxed", "file": "`mdl/grammar/domains/MDLWorkflow.g4` (new `workflowHeaderClause`, `workflowUserTaskClause`, `workflowMultiUserTaskClause`), `mdl/visitor/visitor_workflow.go` (`applyWorkflowUserTaskClause`), `mdl/visitor/visitor_workflow_clauses.go` (`checkWorkflowClausesAtMostOnce`)", "insight": "**Positional reading is what makes a clause order load-bearing, so the grammar fix is a visitor fix.** The tell is `names[idx++]` in an exit-listener: the rule already carried a comment warning that reading strings by position had nearly mis-assigned FOLDER, and the same hazard had simply been left standing for qualified names. **A clause set must re-add the at-most-once rule the sequence gave for free**, or `page M.A page M.B` starts parsing with the second silently winning — a worse failure than the parse error it replaces. Enforce it in the visitor, not the grammar: only there can the message say `duplicate PAGE clause on user task Review (already given on line 12)`. **Two spellings that fill one model slot are ONE clause**: `targeting microflow` + `targeting xpath` were both accepted and the LAST one won, though a user task stores one UserSource — order-dependence in its most damaging form, and now a duplicate. **Keep the MULTI alternative's own clause rule rather than collapsing to `MULTI?`** — relaxing the order must not relax the vocabulary, or a single user task starts accepting `decide by`. **Control that settles it**: build a `bin/mxcli` from HEAD in a `git worktree`, exec the canonical-order script with it, and compare the written `.mxunit` against the fixed binary's output for BOTH orders — 6,038 bytes each, identical in every string ≥8 chars, differing only in the randomly minted element `$ID`s. AST `reflect.DeepEqual` between the two orders is the unit-level version of the same claim; both-parse is not enough, since a relaxed grammar over a positional visitor parses and mis-assigns. Found in passing and NOT fixed here: `create workflow … overview page X` writes nothing (`mdl/backend/modelsdk/workflow_write.go` has no `OverviewPage`), while `alter workflow … set overview page` does. Tests `mdl/visitor/visitor_workflow_clause_order_test.go`; repro `mdl-examples/bug-tests/workflow-586-clause-order.mdl` with its `-canonical.mdl` control and `-duplicate-clause.fail.mdl` sibling", "refs": ["ako/mxcli#586"]} {"area": "mdl/grammar", "date": "2026-09-23", "symptom": "`alter page M.P { set 'createFileAction' = microflow M.ACT_CreateFile on fileUploader1; };` (quoted or bare key) fails to parse: `line 2:37 extraneous input 'MyModule' expecting {DROP, ADD, SET, INSERT, REPLACE, '}'}` — a pluggable widget's NAMED action slot, writable on CREATE PAGE since #956, could only be retargeted by REPLACEing the whole widget", "cause": "`alterPageAssignment` special-cased `Action = actionExprV3` and sent every other key to `propertyValueV3`, which has no `microflow ` form. Below the grammar there was also no route: `SetWidgetProperty` would have stringified an action into `PrimitiveValue`, and `SetWidgetAction` writes the built-in click action, not a pluggable slot", "file": "`mdl/grammar/MDLParser.g4` (`alterPageAssignment`: `STRING_LITERAL|identifierOrKeyword EQUALS actionExprV3`), `mdl/visitor/visitor_alter_page.go` (keeps the author's key), `mdl/executor/cmd_alter_page.go` (`applySetPropertyMutator` routes any `*ast.ActionV3` not keyed `Action`), `mdl/backend/pagemutator/mutator.go` (`SetWidgetNamedAction`); tests `mdl/visitor/visitor_alter_page_named_action_test.go`, `mdl/backend/pagemutator/mutator_named_action_test.go`, `mdl/executor/alter_set_named_action_test.go`; example `mdl-examples/bug-tests/995-alter-page-set-named-action-slot.mdl`", "insight": "**Decide action-slot-ness by the stored PropertyType's `ValueType.Type == \"Action\"`, never by the presence of `Value.Action`** — every WidgetValue carries an Action (a NoAction by default) whatever its type, so a field-presence check would \"succeed\" writing into an Integer and change nothing. Unlike CREATE, ALTER has no datasource overlap to yield to (DataSource is its own alternative), so the action alternative goes before the scalar ones and `microflow M.X` parses straight to an action — no DataSourceV3 conversion. The check-time probe (validate_alter_set.go) dry-runs the same setter, so the wrong-type refusal surfaced at `check -p --references` for free. Measured on 11.12.1: set on a DataGrid 2 with Selection unset (slot hidden) also passed `mx check` at 0 errors — the CE0463 in #956's notes was 11.13.0, so ALTER does not yet run MDL-WIDGET10; don't assume either way without a build on the target version", "refs": ["mendixlabs/mxcli#995", "mendixlabs/mxcli#956"]} +{"area": "mdl/grammar", "date": "2026-09-24", "symptom": "`DROP PAGE IF EXISTS FieldService.Stub;` -> `line 1:13 extraneous input 'EXISTS' expecting the start of a statement`. No document-level DROP (35 alternatives: entity through folder) accepted IF EXISTS, so any script that dropped something was one-shot and the drop had to be deleted after its first run (ako/mxcli#531, ChipCoV3, 11.14.0).", "cause": "`dropStatement` in MDLParser.g4 never applied the existing `ifExists` rule; only sub-document drops (attribute, index, enum value) and the two security drops had it, each added one statement at a time with its own AST field and handler branch.", "file": "`mdl/grammar/MDLParser.g4` (dropStatement); `mdl/ast/ast_drop.go` (DropGuard embedded in 35 Drop*Stmt); `mdl/visitor/visitor_entity.go` (ExitDropStatement sets it once, via defer); `mdl/executor/registry.go` (Dispatch turns NotFoundError into a skip); `mdl/executor/validate.go` (check --references skips guarded drops); tests `mdl/executor/drop_if_exists_test.go`; example `mdl-examples/bug-tests/531-drop-if-exists.mdl`", "insight": "The per-statement recipe the previous IF EXISTS fixes used (field + handler branch) would have been 35 copies, and the next doctype would miss it. What made one central guard safe was a measurement, not an assumption: a table test driving every bare DROP at a missing target through the registry showed all 35 handlers already return mdlerrors.NotFoundError, and only at lookup, before any mutation — so Dispatch can key on that type and let every other error through. Keep that bare-form table test: it is what breaks if a new drop handler reports 'not found' with fmt.Errorf. Mock pitfall on the way: MockBackend's Get*ByQualifiedName defaults to (nil, nil), which the real backend never returns for a missing document — mirror its 'not found' error or the handler dereferences nil. And check --references resolved a drop's MODULE, so a guarded drop in a missing module passed exec but failed check until validateWithContext skipped it too."} diff --git a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl index 7c861ed29..aec27566e 100644 --- a/.claude/skills/fix-issue/findings/mdl-visitor.jsonl +++ b/.claude/skills/fix-issue/findings/mdl-visitor.jsonl @@ -31,3 +31,6 @@ {"area": "mdl/visitor", "date": "2026-09-07", "symptom": "`create or modify snippet M.S (params: { $T: Mod.\"Thing\" })` failed at execution with `failed to resolve entity Mod.\"Thing\": entity not found`, while the identical quoted form in a PAGE parameter resolved fine (ako/CapTrackV4 019).", "cause": "buildSnippetParameterListAsPage re-split the parse node's TEXT (`parseQualifiedName(dt.GetText())`), and GetText() returns the source verbatim, quotes included. The page path walks the parse tree, where buildQualifiedName unquotes each part via identifierOrKeywordText. Fixed by walking the tree; the dead duplicate buildSnippetParameters — a correct implementation nothing called — was removed.", "file": "`mdl/visitor/visitor_page_v3.go` (buildSnippetParameterListAsPage); `mdl/visitor/visitor_page.go` (removed buildSnippetParameters); tests `mdl/visitor/snippet_param_quoted_entity_test.go`", "insight": "GetText() on an ANTLR context is the source text, not the resolved value, so any conversion built on it silently keeps quoting, whitespace and casing that the tree-walking helpers strip. Grep for `parseQualifiedName(.*GetText())` when a name resolves in one statement and not in a sibling. The asymmetry is also the diagnosis: when two statements accept the same syntax and only one works, compare their VISITORS before their executors — here both executor paths were identical and called the same resolveEntity. Two copies of one conversion with one of them dead is how they drifted, so the dead one is deleted rather than fixed."} {"area": "mdl/visitor", "date": "2026-09-15", "symptom": "`placeholder Main { }` inside a CREATE LAYOUT passes `mxcli check`, then fails at exec with `layout \"X\" declares no placeholder` \u2014 a message that flatly contradicts the script, which says `placeholder Main`. The failed exec has already created the module.", "cause": "One grammar rule (placeholderBlockV3) serves two opposite jobs, told apart by shape: `if c.LBRACE() == nil` makes a bodiless placeholder a DECLARATION widget, and the braced form is routed to buildPagePlaceholdersV3 \u2014 the page-side job of FILLING a layout slot. In a layout there is no such job, so the braced form was dropped on the floor and the layout ended up with zero placeholders.", "fix": "Builder gained `inLayout` (saved/restored around the layout body build, since the same body builder serves pages) and collects the dropped names into ast.CreateLayoutStmt.BracedPlaceholders; MDL083 reports them at check time. The braced form is still dropped \u2014 recording it is a diagnostic, not a decision to honour it.", "insight": "When one parse rule serves two documents and is disambiguated by SHAPE rather than by context, the wrong shape has no error path by construction \u2014 it just silently means the other thing. The tell is a runtime message that contradicts the source text. Note the mistake is the natural one: every other layout element takes a body, and `alter page` uses the braced form for real, so the author is generalising correctly from the rest of the language. DESCRIBE emits the bodiless form, so round-tripping never produces it and no existing test covered it.", "controls": "A page's braced placeholder must still fill a slot (TestBuildPageV3_BracedPlaceholderStillFillsASlot) and a layout's bodiless form must still produce a real widget \u2014 the flag is save/restored precisely so a page later in the same script is not flagged.", "refs": "mendixlabs/mxcli#1063", "file": "mdl/visitor/visitor_page_v3.go, mdl/visitor/visitor.go, mdl/ast/ast_page_v3.go"} {"area": "mdl/visitor", "date": "2026-09-16", "symptom": "`$n = COUNT(FILTER($reqs, $currentObject/Status = Mod.E.Approved))` passed `mxcli check`, execed with \"Created microflow\", and then failed the build with CE0012 \"The 'List' property is required.\" at Aggregate list activity 'Count' (mendixlabs/mxcli#1101). `describe` read the stored flow back as `$n = count($)`.", "cause": "buildSetStatementNode converts a SET whose value is a list/aggregate call into an activity statement, and every arm took its list operand through extractVariableName, which handles *ast.VariableExpr and *ast.IdentifierExpr and returns \"\" for anything else with no default branch. A nested call is 'anything else', so the inner call was discarded (list AND predicate) and the activity written with an empty AggregateVariableName. Fixed by recording the dropped operand on the statement (ast.UnresolvedOperand) and refusing it at check time as MDL-LISTOP02.", "file": "`mdl/visitor/visitor_microflow_statements.go` (buildListOrAggregateStatement, recordUnresolvedOperands); `mdl/ast/ast_microflow.go` (UnresolvedOperand); `mdl/executor/validate_microflow_listop_source.go`; tests `mdl/executor/validate_microflow_listop_source_test.go`, `mdl-examples/bug-tests/1101-nested-list-operand-dropped.fail.mdl`", "insight": "A type switch with no default over AST nodes is a silent data-loss site, and `extractVariableName`-shaped helpers ('return the name, or \"\"') hide it behind a value that reads as absence. The measurement that mapped the blast radius was not reading code: exec each spelling into its own copy of a project and run mxbuild, one microflow per project so the error count is unambiguous. That turned a COUNT bug into six — head/tail/filter/sort/union and a bare string literal — and found one strictly worse than the report: `sort(filter(…), Name)` makes mxbuild ABORT with InvalidOperationException rather than report an error, because the sort attribute resolves against the absent list's entity, so the document cannot be loaded at all. Put the control in the SAME project as the defect where possible: the reporter's two-statement workaround next to the nested form gave `The app contains: 1 errors`, which proves the rule's scope in one build. When several arms each do the same bookkeeping inline, move them to one tail rather than adding the bookkeeping N times — this file's own buildSetAggregate comment already says that is how the attribute went missing before.", "refs": "mendixlabs/mxcli#1101; sibling rule MDL-LISTOP01 (#1002); related surface-syntax issue mendixlabs/mxcli#750 (expressions vs. what the model can store)"} +{"date": "2026-09-24", "area": "mdl-visitor", "symptom": "upstream #1174: DESCRIBE printed a view whose join is `AS ROLE`, and exec'ing that output failed — `line 7:62 mismatched input 'ROLE' expecting IDENTIFIER` then a knock-on `mismatched input ')' expecting {SELECT, HAVING}`. The model was valid (mx check 0 errors on 11.12.1)", "cause": "`tableReference` / `joinClause` took the source alias as `AS? IDENTIFIER`, and `associationPath`'s leading alias as `IDENTIFIER`, so any MDL keyword (ROLE, STATUS, VALUE…) was refused although OQL does not reserve it. The select alias already accepted `keyword`; the source alias had never been given the same", "file": "`mdl/grammar/domains/MDLCatalog.g4` (new `oqlSourceAlias`: `AS (IDENTIFIER | keyword) | IDENTIFIER`; `associationPath` leading `(IDENTIFIER | keyword)`); `mdl/visitor/visitor_entity.go` (`oqlSourceAliasText`); tests `mdl/visitor/oql_keyword_alias_test.go`; example `mdl-examples/bug-tests/1174-oql-keyword-source-alias.mdl`", "insight": "**Accept the keyword only after an explicit AS.** Copying `selectAlias`'s `IDENTIFIER | keyword` into `AS? …` would let `from M.Sale s left join …` read LEFT as the alias — the bare form has to stay IDENTIFIER-only, and the test keeps a control for exactly that. MDL's keyword list is not OQL's: rule 4 of `mxcli syntax domain-model.view-entity.oql` (rename a reserved alias) is about OQL's own words (Month, Year) and does not apply to an MDL-only keyword, so do not send the user to rename. Measured end-to-end on 11.12.1: exec + `mx check` 0 errors, DESCRIBE prints `AS ROLE`, and the described text re-parses and execs. Two gaps met on the way, both independent of the keyword and filed separately: an uppercase `AS` on an association-path join leaves that alias unresolved in `extractAliasMap` (case-sensitive `TrimSuffix(path, \"as\")`), so its columns get no type check — first misread as 'System entity lengths are not checked' until bisecting the query text against a fresh project (ako/mxcli#652); and describe -> exec adds 2 spaces to OQL lines 2..n on EVERY cycle — re-running the same described file said Unchanged and hid it, only describing again between runs shows the drift (ako/mxcli#653)", "refs": ["mendixlabs/mxcli#1174", "ako/mxcli#652", "ako/mxcli#653"]} +{"area": "mdl/visitor", "date": "2026-09-24", "symptom": "`datagrid dg (DataSource: Mod.Car, …)` — the bare-entity shorthand — passed `check` (no project), `exec --no-check` printed \"Created page\", `describe page` showed `datagrid dg (onClick: …)` with the source gone, and mxbuild 11.14.0 reported CE0488 \"No entity configured for the data source of this widgets container\" + CE1571 + two column-attribute errors on that one grid. With `-p`, the reference pass refused it instead, but with the misleading \"Attribute 'Name' is bound but there is no enclosing data container providing entity context\" on a grid whose source the script did name.", "cause": "Every dataSourceExprV3 alternative starts with a keyword (DATABASE/MICROFLOW/…) or a VARIABLE, so `DataSource: Mod.Car` matched none and fell through to the generic `keyword COLON propertyValueV3` branch at the end of widgetPropertyV3 (DATASOURCE is in `keyword`). The visitor stored the string \"Mod.Car\"; GetDataSource() only type-asserts *ast.DataSourceV3, so nothing downstream saw it.", "file": "`mdl/visitor/visitor_page_v3.go` (bareEntityDataSource, bareEntityWidgets)", "insight": "Resolve the shorthand in the VISITOR, keyed on widget type (datagrid/listview/gallery/dataview), not as a grammar alternative. The first cut added `DATASOURCE COLON qualifiedName` to widgetPropertyV3 and broke the Barcode Scanner: a pluggable widget's generic keys are its own .mpk keys, and its `datasource: Module.Entity.Code` binds an ATTRIBUTE (Image's `datasource` is an enum) — only the widget type says what the key means. Grep `\"propertyKey\": \"datasource\"` in modelsdk/widgets/definitions before giving a common word a meaning. The tell for the class is a `map[string]any` property whose readers type-assert: a value of the wrong Go type is invisible rather than wrong. Side effects worth knowing: a data view with the shorthand (maint2-editable-never-create-page.mdl had one, unbound and unnoticed) is now refused as MDL-WIDGET09 instead of written unbound; `-p` reference checking on main already refused the grid case but blamed the column ('no enclosing data container'), a downstream symptom reading as user error; ALTER `set DataSource = M.E` was never silent (refuses 'must be a datasource expression'). Control: pre-fix binary + `exec --no-check` on a fresh 11.14.0 app reproduced the issue's four mxbuild errors verbatim; fixed binary, same script, 0 errors.", "refs": ["ako/mxcli#576", "ako/mxcli#552"], "ce": ["CE0488", "CE1571"]} +{"area":"mdl-visitor","date":"2026-09-24","refs":["#653"],"symptom":"describe entity on a view entity, exec'd back, was never idempotent: \"Each cycle reports `Modified view entity` and stores the query with every line after the first indented two spaces further.\" Re-exec of the SAME described file reported Unchanged, so it looked stable until you described again","cause":"The two directions did not mirror: describe (cmd_entities_describe.go, and cmd_diff_mdl.go) prefixes two spaces to every stored OQL line; the visitor stored extractOriginalText(oqlCtx), which starts at the query's first token, so line 1 lost its indentation and lines 2…n kept all of it — +2 per cycle","file":"`mdl/visitor/visitor_entity.go` (dedentOQL, leadingLineWhitespace); tests `mdl/visitor/visitor_view_entity_oql_indent_test.go`, `mdl/executor/view_entity_oql_roundtrip_test.go`; bug-test `mdl-examples/bug-tests/653-view-entity-oql-indent-drift.mdl`","insight":"**Any verbatim-source capture that starts at the first token is asymmetric**: line 1 is dedented for free, the continuation lines are not. Fix it on the way IN (exec), not by making describe emit less: strip the common leading-whitespace prefix of the non-blank lines, counting line 1 at its column when only whitespace precedes it (read it from the input stream, start.GetStart()-GetColumn()). Compare prefixes byte-wise, not by width, so a Studio Pro query indented with tabs comes back byte-identical under describe's two spaces. A round-trip test must run describe → exec at least twice AND start from stored text mxcli did not write (flat, tabs, blank lines, comments): one pass from a script is exactly how this went unnoticed. Control: stubbing dedentOQL to return raw fails every round-trip case with lines 2…n two spaces deeper; a real 11.12.1 project with the old binary printed Modified ×3 with growing indent, the fixed one Unchanged ×3. Separate, not fixed here: a comment AFTER the query's last token is outside the captured span and is dropped on exec"} diff --git a/.claude/skills/mendix/write-layouts/SKILL.md b/.claude/skills/mendix/write-layouts/SKILL.md index 614cc1d59..8a9055d5f 100644 --- a/.claude/skills/mendix/write-layouts/SKILL.md +++ b/.claude/skills/mendix/write-layouts/SKILL.md @@ -148,6 +148,32 @@ create page MyModule.Home (title: 'Home', layout: MyModule.App_Default) { | Placeholder | `placeholder Main` | The hole a page's content goes into. No properties, no body | | Navigation tree | `navigationtree name (profile: 'Responsive')` | The sidebar menu — vertical. The profile is a navigation profile name | | Menu bar | `menubar name (profile: 'Responsive')` | The topbar menu — horizontal. Same stored shape as a navigation tree | +| Simple menu bar | `simplemenubar name (menu: Module.Menu, class: 'bottom-nav-text-icons')` | A **phone** layout's bottom bar. `orientation: Vertical` for the other direction | + +**A menu widget renders a profile OR a menu document.** `profile: 'Phone'` draws +the navigation profile's menu; `menu: Module.Menu` draws a standalone menu +document (`create menu`, see `mxcli syntax navigation.menu-document`). Naming +both is refused. Atlas's `Phone_BottomBar` points its simple menu bar at +`Atlas_Core.Phone_Menu` — the blank app's *sample* menu (Home / Layouts / +Templates / Widgets), which is why a phone app on that layout shows those items +instead of its own. The fix is a phone layout of your own whose bottom bar names +your own menu document, not an edit to Atlas's layout: + +```sql +create or modify menu MyModule.Phone_Menu ( + menu item 'Home' page MyModule.Home_Phone icon Atlas_Core.Atlas.home; +); +create or replace layout MyModule.Phone_Bottom ( + layouttype: 'Phone', class: 'layout-atlas layout-atlas-phone' +) { + scrollcontainer scrollContainer1 { + region bottom (class: 'region-bottombar') { + simplemenubar bottomBar (menu: MyModule.Phone_Menu, class: 'bottom-nav-text-icons') + } + region center (class: 'region-content') { placeholder Main } + } +} +``` Region properties: `size` (integer), `sizemode` (`Fixed` / `Pixels` / `Auto`), `class`. Unset is Studio Pro's `200` / `Auto`. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8b6e66b..3b8ad628c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ Headline: **An element's storage GUID is the database's identity, and mxcli now ### Fixed +- **A phone layout could not have a bottom bar** (ako/mxcli#573) — Atlas's `Phone_BottomBar` carries a `Forms$SimpleMenuBar`, and `describe layout` answered `-- Forms$SimpleMenuBar (simpleMenuBar1) -- NOT re-executable: mxcli cannot author this widget, so re-running this script would drop it`. MDL gains `simplemenubar name (menu: Module.Menu [, orientation: Horizontal|Vertical])`, and all three menu widgets (`navigationtree`, `menubar`, `simplemenubar`) now take `menu: Module.Menu` — a menu document, stored as a `Forms$MenuDocumentSource` — as the alternative to `profile: '…'`; naming both is refused. `describe` emits the widget and its menu reference, so a copy of Atlas's phone layout keeps its bottom bar. Measured on 11.14.0: a layout with a simple menu bar on a menu document, a vertical one on a profile, and a menu bar and navigation tree on a menu document → `mx check` 0 errors. A menu reference that does not resolve fails `mx check` with CE1613, and `mxcli check --references` now reports it first — layouts were not reference-checked at all before, though they are where menu widgets live. + - **An input inside a list view rendered disabled with every gate green** (ako/mxcli#631) — a `listview` that does not say `editable: true` is written `Editable: false`, and the list view's read-only context wins over `editable: Always` on the input, including inside a nested data view. The BSON is valid, `mx check` is clean and the build succeeds; only the running app shows it. The default is not changed: false is Mendix's own (mendixmodelsdk 4.115.0, `Pages$ListView.editable` defaults to false). `mxcli check` now warns **MDL-WIDGET31** on a list view that will be written read-only while it holds an input not marked `editable: Never`, and names `editable: true` as the fix. A quoted `editable: 'true'` is a string and is written false, so it warns too. - **A queued call to a microflow that returns a value passed `mxcli check --references` and failed the build with CE7033** (mendixlabs/mxcli#1064) — `call microflow M.F() in queue M.Q` where `F` returns Boolean said "Check passed!", and `mx check` then reported `[CE7033] "A microflow used for background execution must have a Microflow return type of 'Nothing'."`. Both the call and the queue resolve; the constraint is on the flow the call names. **MDL088** now reports it — with no project when the script creates the microflow itself, and under `--references` for one already stored. Measured on mxbuild 11.12.0: a Boolean target → CE7033, the same call on a void target → 0 errors. diff --git a/cmd/mxcli/lsp_completions_gen.go b/cmd/mxcli/lsp_completions_gen.go index effe71bab..5904ebce8 100644 --- a/cmd/mxcli/lsp_completions_gen.go +++ b/cmd/mxcli/lsp_completions_gen.go @@ -187,6 +187,7 @@ var mdlGeneratedKeywords = []protocol.CompletionItem{ {Label: "SCROLLCONTAINER", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "REGION", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "MENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, + {Label: "SIMPLEMENUBAR", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "NAVIGATIONTREE", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "SNIPPETCALL", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, {Label: "LAYOUTGRID", Kind: protocol.CompletionItemKindKeyword, Detail: "Widget keyword"}, diff --git a/cmd/mxcli/syntax/features_domain_model.go b/cmd/mxcli/syntax/features_domain_model.go index b3175a736..f7401a700 100644 --- a/cmd/mxcli/syntax/features_domain_model.go +++ b/cmd/mxcli/syntax/features_domain_model.go @@ -199,7 +199,8 @@ func init() { " 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" + + " that one is renamed, not quoted. An MDL keyword that is NOT an OQL one\n" + + " (Role, Status, Value) is a fine FROM / JOIN alias after `as`.\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" + diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index ce5f2bf9d..7a6f16eec 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -42,6 +42,42 @@ func init() { SeeAlso: []string{"microflow", "domain-model.entity", "page", "document-folder"}, }) + // IF EXISTS sits on every document-level alternative of dropStatement, so it + // is documented once here for the same reason OR MODIFY is (#531). + Register(SyntaxFeature{ + Path: "drop-if-exists", + Summary: "DROP … IF EXISTS — a drop that can be re-run", + Keywords: []string{ + "drop if exists", "if exists", "drop", "re-run", "rerun", + "idempotent", "not found", "replayable", "cleanup", + }, + Syntax: "DROP IF EXISTS Module.Name;\n" + + "DROP MODULE IF EXISTS ModuleName;\n" + + "DROP CONFIGURATION IF EXISTS 'Name';\n" + + "DROP FOLDER IF EXISTS 'path' IN Module;\n\n" + + "-- Every document-level DROP accepts IF EXISTS: entity, association,\n" + + "-- enumeration, constant, microflow, nanoflow, rule, page, layout,\n" + + "-- snippet, menu, module, queue, scheduled event, regular expression,\n" + + "-- java/javascript action, odata client/service, business event service,\n" + + "-- workflow, image collection, json structure, message definition\n" + + "-- collection, import/export mapping, rest client, published rest\n" + + "-- service, data transformer, model, consumed mcp service, knowledge\n" + + "-- base, agent, configuration, folder.\n" + + "--\n" + + "-- A missing target -- or a missing module -- is reported as skipped\n" + + "-- instead of stopping the script. Any other failure still errors. The\n" + + "-- bare DROP keeps SQL semantics and fails on a missing target.\n" + + "--\n" + + "-- Sub-document drops have their own guard: ALTER ENTITY … DROP ATTRIBUTE\n" + + "-- IF EXISTS, DROP INDEX IF EXISTS, ALTER ENUMERATION … DROP VALUE IF\n" + + "-- EXISTS, DROP USER ROLE IF EXISTS, DROP DEMO USER IF EXISTS.", + Example: "-- a stub that broke a page/workflow cycle, dropped once the real page exists\n" + + "DROP PAGE IF EXISTS FieldService.Stub;\n" + + "DROP MICROFLOW IF EXISTS FieldService.ACT_Old;\n" + + "DROP FOLDER IF EXISTS 'Scratch' IN FieldService;", + SeeAlso: []string{"create-modifiers"}, + }) + // The folder clause is the other cross-cutting CREATE modifier, and gets one // topic for the same reason OR MODIFY does: it applies to every document // type, so documenting it in all 27 places would guarantee 27 chances to diff --git a/cmd/mxcli/syntax/features_page.go b/cmd/mxcli/syntax/features_page.go index 3b1b8602d..6094acbda 100644 --- a/cmd/mxcli/syntax/features_page.go +++ b/cmd/mxcli/syntax/features_page.go @@ -447,6 +447,7 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { Keywords: []string{ "layout", "layouts", "create layout", "scrollcontainer", "region", "placeholder", "navigationtree", "topbar", "sidebar", "frame", + "menubar", "simplemenubar", "bottom bar", "phone layout", "menu document", }, Syntax: "CREATE [OR REPLACE] LAYOUT Module.Name (\n" + " layouttype: 'Responsive' | 'Phone' | 'Tablet' | 'ModalPopup' -- web\n" + @@ -460,6 +461,10 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { " -- widgets, plus:\n" + " NAVIGATIONTREE name (Profile: 'Responsive') -- vertical, for a sidebar\n" + " MENUBAR name (Profile: 'Responsive') -- horizontal, for a topbar\n" + + " SIMPLEMENUBAR name (Menu: Module.Menu -- a phone layout's bottom bar\n" + + " [, Orientation: Horizontal | Vertical])\n" + + " -- Every menu widget renders EITHER a navigation profile (Profile: '…')\n" + + " -- OR a menu document (Menu: Module.Menu, see CREATE MENU) — not both.\n" + " PLACEHOLDER Main\n" + " }\n" + " }\n" + @@ -487,6 +492,24 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) { " }\n" + " }\n" + "}\n\n" + + "-- A phone layout's bottom bar, as Atlas_Core.Phone_BottomBar has it —\n" + + "-- a simple menu bar rendering a menu document:\n" + + "CREATE OR MODIFY MENU MyModule.Phone_Menu (\n" + + " menu item 'Home' page MyModule.Home_Phone icon Atlas_Core.Atlas.home;\n" + + ");\n" + + "CREATE OR REPLACE LAYOUT MyModule.Phone_Bottom (\n" + + " layouttype: 'Phone',\n" + + " class: 'layout-atlas layout-atlas-phone'\n" + + ") {\n" + + " SCROLLCONTAINER scrollContainer1 {\n" + + " REGION bottom (Class: 'region-bottombar') {\n" + + " SIMPLEMENUBAR bottomBar (Menu: MyModule.Phone_Menu, Class: 'bottom-nav-text-icons')\n" + + " }\n" + + " REGION center (Class: 'region-content') {\n" + + " PLACEHOLDER Main\n" + + " }\n" + + " }\n" + + "}\n\n" + "-- A page binds to a placeholder by name:\n" + "CREATE PAGE MyModule.Home (Title: 'Home', Layout: MyModule.App_Default) {\n" + " TITLE t (Content: 'Welcome')\n" + diff --git a/cmd/mxcli/syntax/widget_keywords_drift_test.go b/cmd/mxcli/syntax/widget_keywords_drift_test.go index 622b902ed..e7c425fcd 100644 --- a/cmd/mxcli/syntax/widget_keywords_drift_test.go +++ b/cmd/mxcli/syntax/widget_keywords_drift_test.go @@ -95,6 +95,7 @@ var documentedElsewhere = map[string]string{ "scrollregion": "layout", "navigationtree": "layout", "menubar": "layout", + "simplemenubar": "layout", "placeholder": "layout", // Object-list container keywords for pluggable widgets: each is the singular // form of one widget's own list property (Accordion groups → GROUP), routed diff --git a/docs-site/src/reference/page/create-layout.md b/docs-site/src/reference/page/create-layout.md index 201a7ffa4..ef3b92bfe 100644 --- a/docs-site/src/reference/page/create-layout.md +++ b/docs-site/src/reference/page/create-layout.md @@ -121,6 +121,12 @@ Main, not by a property) | Placeholder | `PLACEHOLDER Main` | The hole a page's content goes into. No properties, no body | | Navigation tree | `NAVIGATIONTREE name (profile: 'Responsive')` | The sidebar menu — vertical | | Menu bar | `MENUBAR name (profile: 'Responsive')` | The topbar menu — horizontal | +| Simple menu bar | `SIMPLEMENUBAR name (menu: Module.Menu [, orientation: Vertical])` | A phone layout's bottom bar, as Atlas's `Phone_BottomBar` has it. `orientation` defaults to `Horizontal` | + +Every menu widget renders **either** a navigation profile (`profile: 'Responsive'`) +**or** a menu document (`menu: Module.Menu`, created with `CREATE MENU`) — the two +are alternatives for the same slot, and naming both is refused. With neither, the +`Responsive` profile is used. ## Examples diff --git a/docs-wiki/bug-patterns/scripts-that-cannot-rerun.md b/docs-wiki/bug-patterns/scripts-that-cannot-rerun.md index 98e1b80fe..ff92aea28 100644 --- a/docs-wiki/bug-patterns/scripts-that-cannot-rerun.md +++ b/docs-wiki/bug-patterns/scripts-that-cannot-rerun.md @@ -53,6 +53,17 @@ when the handler exists and `DROP EVENT HANDLER` errors when it does not, so neither a plain script nor a defensive drop-then-add is re-runnable. A statement family needs the guard on both halves or the workaround is unavailable too. +**Put the guard where the family is, not on each member.** `IF EXISTS` arrived +one statement at a time — attribute, index, enum value, user role, demo user — +each with its own AST field and handler branch, and the document-level `DROP` +(35 doctypes) still had none. It now lives once: the grammar rule on every +alternative, one flag the visitor sets, and one check in the executor's +dispatch. That is safe only because every drop handler reports a missing +target the same way (`NotFoundError`, at lookup, before mutating), which a +table test over every bare `DROP` measures. A new drop handler that says "not +found" some other way fails that test rather than silently erroring under the +guard. + **A list rule without a separator reads as a value error.** Several parse failures in this area were reported against the *value* in the second item of a list — `add attribute A: integer default 9, add attribute B: …` — when the list diff --git a/docs/01-project/MDL_QUICK_REFERENCE.md b/docs/01-project/MDL_QUICK_REFERENCE.md index d4125f88d..67ca255ca 100644 --- a/docs/01-project/MDL_QUICK_REFERENCE.md +++ b/docs/01-project/MDL_QUICK_REFERENCE.md @@ -15,6 +15,10 @@ describe page Sales.Order; -- the explicit form still works and is requir If a name matches more than one document (e.g. an entity and a snippet share a name), the bare form reports the candidates and asks you to specify the type. The explicit form is also still required for things that have no single qualified name (module role, user role, settings, navigation). +## DROP — `if exists` on every document + +Every document-level `drop` accepts `if exists` between the document type and its name — `drop page if exists Module.Stub;`, `drop module if exists Scratch;`, `drop folder if exists 'Old' in Module;`. A missing target (or a missing module) is reported as skipped instead of stopping the script, so a script that drops something can be re-run. Any other failure still errors. The bare form keeps SQL semantics and fails on a missing target. + ## Entity Generalization (EXTENDS) **CRITICAL: EXTENDS goes BEFORE the opening parenthesis, not after!** @@ -38,7 +42,7 @@ create persistent entity Module.Photo ( | Show modules | `show modules;` | List all modules | | Describe module | `describe module ModuleName;` | All contents (entities, microflows, pages, etc.) | | Create module | `create module ModuleName;` | | -| Drop module | `drop module ModuleName;` | | +| Drop module | `drop module [if exists] ModuleName;` | | | Rename module | `rename module OldName to NewName;` | Updates all qualified name references | ## Module JAR Dependencies @@ -66,7 +70,7 @@ create persistent entity Module.Photo ( | View entity → persistent entity | `select t.ID as MyRef, …` in the OQL | Selecting the target's **id** under an alias gives the view entity an **association** named after the alias. It is not an attribute and gets no declaration: the column *is* the declaration, so mxcli creates the member (with the `OqlViewAssociationSource` mxbuild requires — without it, CE6771 + CE6770). A plain `create association` with a view entity at either end is **refused**. The alias must be free in the module, case-insensitively. `cast(t.ID as string) as MyId` is a plain String attribute instead — one query rather than two, no objects in the client | | Create external entity | `create external entity Module.Name from odata client Module.Client (...) (attrs);` | From consumed OData | | Create external entities | `create [or modify] external entities from Module.Client [into module] [entities (...)];` | Bulk from $metadata | -| Drop entity | `drop entity Module.Name;` | | +| Drop entity | `drop entity [if exists] Module.Name;` | | | Describe entity | `describe entity Module.Name;` | Full MDL output | | Describe enumeration | `describe enumeration Module.Name;` | Full MDL output. **`System.*` enumerations are included** — they are platform built-ins with no stored unit, synthesized so their values are discoverable instead of guessed at until **CE1613**. They are read-only: `describe` prints them as `--` comment lines, and `create`/`alter`/`drop`/`move` naming the System module is refused | | Rename entity | `rename entity Module.Old to New;` | Updates all references | @@ -75,9 +79,9 @@ create persistent entity Module.Photo ( | Show entities | `show entities [in module];` | List all or filter by module | | Create enumeration | `create [or modify] enumeration Module.Name (Value1 'caption', ...);` | | | Alter enumeration values | `alter enumeration Module.Name add value [if not exists] X [caption '..'] \| rename value X to Y \| modify value X caption '..' \| drop value [if exists] X;` | `modify value … caption` re-captions in place (works while referenced). `if not exists` / `if exists` make the script re-runnable — the bare forms error and stop the run | -| Drop enumeration | `drop enumeration Module.Name;` | Refused for `System.*` (read-only platform module) | +| Drop enumeration | `drop enumeration [if exists] Module.Name;` | Refused for `System.*` (read-only platform module) | | Create association | `create [or modify] association Module.Name from Parent to Child type reference\|ReferenceSet [owner default\|both] [delete_behavior ...];` | OR MODIFY updates existing association in-place. **The FROM entity must live in `Module`** — Mendix stores an association in its FROM entity's module, so a remote FROM writes a dangling pointer and the project stops OPENING (**MDL070**). The TO entity may be remote; that direction is stored BY NAME | -| Drop association | `drop association Module.Name;` | | +| Drop association | `drop association [if exists] Module.Name;` | | | Association line anchors | `@anchor(from: (0, 54), to: (100, 54))` above `create association …` | Where the connector attaches to each entity box, as a **percentage** of the box (0..100, whole numbers). `from` = the FROM entity's box, `to` = the TO entity's. Omitting an end preserves what is stored, so a `create or modify` about something else never flattens a hand-tuned line. Cross-module associations have no anchors — Mendix stores none | | Retune anchors in place | `alter association Module.Name set anchor from (50, 100) to (50, 0);` | `(0, 50)` left-middle, `(100, 50)` right-middle, `(50, 100)` bottom-centre. `describe association` re-emits a non-default pair as the same `@anchor(...)`, so describe → edit → exec round-trips | @@ -146,7 +150,7 @@ alter entity Sales.Customer | Show constant values | `show constant values [in module];` | Compare values across configurations | | Describe constant | `describe constant Module.Name;` | Full MDL output | | Create constant | `create [or modify] constant Module.Name type DataType default 'value';` | String, Integer, Boolean, etc. | -| Drop constant | `drop constant Module.Name;` | | +| Drop constant | `drop constant [if exists] Module.Name;` | | A per-configuration override holds either a **shared** value (in the model, so in version control) or a **private** one (on the developer's own workstation, out of the @@ -169,7 +173,7 @@ create constant MyModule.EnableLogging type boolean default true; | Show queues | `show queues [in module];` (`list queues` too) | Parallelism + cluster-wide flag | | Describe queue | `describe queue Module.Name;` | Re-executable MDL | | Create queue | `create [or modify] queue Module.Name [folder 'path'] ( Parallelism: 3, ClusterWide: true );` | Body optional; defaults `1` / `false` | -| Drop queue | `drop queue Module.Name;` | | +| Drop queue | `drop queue [if exists] Module.Name;` | | `Parallelism` is an **expression**, not a number — Mendix stores it as a string (`Queues$BasicQueueConfig.ParallelismExpression`). A bare integer is the common @@ -199,7 +203,7 @@ Named patterns, shared by attribute validation rules. | Show regular expressions | `show regular expressions [in module];` (`list` too) | Pattern + documentation | | Describe regular expression | `describe regular expression Module.Name;` | Re-executable MDL | | Create regular expression | `create [or modify] regular expression Module.Name [folder 'path'] ( Expression: '' );` | `Expression` required | -| Drop regular expression | `drop regular expression Module.Name;` | | +| Drop regular expression | `drop regular expression [if exists] Module.Name;` | | A regex is a **document**, not a string on a rule: Mendix stores a validation rule's reference to it by qualified name, so one pattern is shared by every @@ -274,7 +278,7 @@ Mendix's cron: run a microflow on a repeating schedule. | Show scheduled events | `show scheduled events [in module];` (`list` too) | Repeat, microflow, enabled | | Describe scheduled event | `describe scheduled event Module.Name;` | Re-executable MDL | | Create scheduled event | `create [or modify] scheduled event Module.Name [folder 'path'] ( Microflow: ..., Repeat: ..., ... );` | | -| Drop scheduled event | `drop scheduled event Module.Name;` | | +| Drop scheduled event | `drop scheduled event [if exists] Module.Name;` | | `Microflow` and `Repeat` are always required. Each repeat takes **only** its own fields — anything else is refused by `mxcli check` (MDL-SCHED01) and by `exec`: @@ -325,13 +329,13 @@ create scheduled event Ops.WeeklyReport ( | Describe OData client | `describe odata client Module.Name;` | Full MDL output | | Create OData client | `create [or modify] odata client Module.Name (...);` | Version, MetadataUrl, Timeout, etc. | | Alter OData client | `alter odata client Module.Name set key = value;` | | -| Drop OData client | `drop odata client Module.Name;` | | +| Drop OData client | `drop odata client [if exists] Module.Name;` | | | Show OData services | `show odata services [in module];` | Published OData services | | Describe OData service | `describe odata service Module.Name;` | Full MDL output | | Create OData service | `create [or modify] odata service Module.Name (...) authentication ... { publish entity ... };` | | | Publish as GraphQL too | `create odata service Module.Name (SupportsGraphQL: Yes) {...};` | Mendix 10.14+. Same location, clients POST a query. Exposed names must be unique beyond case (CE2881); query fields are camelCased | | Alter OData service | `alter odata service Module.Name set key = value;` | | -| Drop OData service | `drop odata service Module.Name;` | | +| Drop OData service | `drop odata service [if exists] Module.Name;` | | | Show external entities | `show external entities [in module];` | OData-backed entities | | Show external actions | `show external actions [in module];` | Actions used in microflows | | Create external entity | `create [or modify] external entity Module.Name from odata client Module.Client (...) (attrs);` | | @@ -474,8 +478,8 @@ rather than updating the first. | Rename nanoflow | `rename nanoflow Module.Old to New;` | Updates all references | | Rename page | `rename page Module.Old to New;` | Updates all references | | Rename constant | `rename constant Module.Old to New;` | Updates all references | -| Drop microflow | `drop microflow Module.Name;` | | -| Drop nanoflow | `drop nanoflow Module.Name;` | | +| Drop microflow | `drop microflow [if exists] Module.Name;` | | +| Drop nanoflow | `drop nanoflow [if exists] Module.Name;` | | | Create nanoflow | `create [or modify] nanoflow Module.Name (params) returns type [folder 'path'] begin ... end;` | Same body syntax as microflows | | Expose in the toolbox | `create microflow Module.Name () exposed as microflow action 'Caption' in 'Category' begin ... end;` | Studio Pro's "Expose as microflow action". A microflow has **two** toolbox entries, so the clause names which | | Expose to the workflow editor | `... exposed as workflow action 'Caption' in 'Category' ...` | The second entry; both may be set on one microflow | @@ -486,7 +490,7 @@ rather than updating the first. | Show rules | `show rules [in module];` | `list rules` is the same statement | | Describe rule | `describe rule Module.Name;` | Round-trippable | | Create rule | `create [or modify] rule Module.Name (params) returns Boolean\|enum Module.Enum [folder 'path'] begin ... end;` | Same body syntax as microflows | -| Drop rule | `drop rule Module.Name;` | | +| Drop rule | `drop rule [if exists] Module.Name;` | | | Move rule | `move rule Module.Name to folder 'path';` | | | Call a rule | `if Module.Rule_Name(Param = $Value) then ... end if;` | A decision is the ONLY place a rule can be called | | Rule restrictions | N/A | Return type must be Boolean or an enumeration (mxbuild CE0103/CE0139); no create/change/delete/commit/rollback, no client interaction, no web-service calls (CE0009). There is no `grant execute on rule` — a rule stores no module-role security | @@ -618,7 +622,7 @@ and `mxbuild` were all clean. Only the running app showed it. | List folders | `list folders [in module];` | The folder layout, with the documents in each folder | | Microflow folder | `folder 'path'` (before BEGIN) | `create microflow ... folder 'ACT' begin ... end;` | | Page folder | `folder: 'path'` (in properties) | `create page ... (folder: 'pages/Detail') { ... }` | -| Drop folder | `drop folder 'path' in module;` | Folder must be empty | +| Drop folder | `drop folder [if exists] 'path' in module;` | Folder must be empty | | Move folder | `move folder Module.FolderName to folder 'path';` | Target folders auto-created | | Move to folder | `move Module.Name to folder 'path';` | Folders created automatically. Any top-level doctype, spelled as `describe` spells it | | Move a mapping / structure | `move import mapping\|export mapping\|json structure Module.Name to folder 'path';` | | @@ -671,7 +675,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a | Show workflows | `show workflows [in module];` | List all or filter by module | | Describe workflow | `describe workflow Module.Name;` | Full MDL output | | Create workflow | `create [or modify] workflow Module.Name [folder 'path'] parameter $Ctx: Module.Entity [on workflow events (, ...) microflow Mod.MF [as '']] [on any workflow event microflow Mod.MF [as '']] begin ... end workflow;` | See activity types and event handlers below | -| Drop workflow | `drop workflow Module.Name;` | | +| Drop workflow | `drop workflow [if exists] Module.Name;` | | The **overview page** must accept a `System.Workflow` parameter — the build fails `CE7410 "The selected page … should accept a parameter of type @@ -960,7 +964,7 @@ still flagged rather than guessed at. | Drop constant override | `alter settings drop constant 'Name' in configuration 'cfg';` | Reset to default value | | Create or modify configuration | `create or modify configuration 'Name' [key = value, ...];` | Upsert — what `describe settings` emits, so a described project replays onto a target that already has `Default` | | Create configuration | `create configuration 'Name' [key = value, ...];` | New server configuration. `DatabaseType` must be `Db2`, `Hsqldb`, `MySql`, `Oracle`, `PostgreSql`, `SapHana` or `SqlServer` (case-insensitive) | -| Drop configuration | `drop configuration 'Name';` | Remove a configuration | +| Drop configuration | `drop configuration [if exists] 'Name';` | Remove a configuration | | Alter language | `alter settings LANGUAGE key = value;` | DefaultLanguageCode (must already be enabled). Set it **before** creating pages — it decides what language their captions are stored in | | Enable a language | `alter settings LANGUAGE add 'de_DE' [(CheckCompleteness: true, CustomDateFormat: 'yyyy-MM-dd')];` | Adds to the enabled list — the only languages a build emits translations for. A language is identified by its code; Studio Pro's "German, Germany" is derived for display and not stored | | Enable or modify (upsert) | `alter settings LANGUAGE add or modify 'de_DE' (CheckCompleteness: true);` | What `describe settings` emits, so a described project replays onto itself or onto one that already has the language | @@ -983,7 +987,7 @@ still flagged rather than guessed at. | Describe service | `describe business event service Module.Name;` | Full MDL output | | Create service | `create business event service Module.Name (...) { message ... };` | See help topic for full syntax | | Create or modify | `create or modify business event service Module.Name (...) { ... };` | Preserves UUID — preferred for AI agents | -| Drop service | `drop business event service Module.Name;` | Delete a service | +| Drop service | `drop business event service [if exists] Module.Name;` | Delete a service | ## Agents @@ -997,7 +1001,7 @@ the `AgentEditorCommons` marketplace module and Mendix 11.9+. | List models | `list models [in module];` | Also `show models` | | Describe model | `describe model Module.Name;` | Full MDL output | | Create model | `create [or modify] model Module.Name (Provider: MxCloudGenAI, key: Module.Const);` | OR MODIFY updates existing model, preserves UUID | -| Drop model | `drop model Module.Name;` | | +| Drop model | `drop model [if exists] Module.Name;` | | **Knowledge Base** @@ -1006,7 +1010,7 @@ the `AgentEditorCommons` marketplace module and Mendix 11.9+. | List knowledge bases | `list knowledge bases [in module];` | Also `show knowledge bases` | | Describe knowledge base | `describe knowledge base Module.Name;` | Full MDL output | | Create knowledge base | `create [or modify] knowledge base Module.Name (Provider: MxCloudGenAI, key: Module.Const);` | OR MODIFY updates existing KB, preserves UUID | -| Drop knowledge base | `drop knowledge base Module.Name;` | | +| Drop knowledge base | `drop knowledge base [if exists] Module.Name;` | | **Consumed MCP Service** @@ -1015,7 +1019,7 @@ the `AgentEditorCommons` marketplace module and Mendix 11.9+. | List MCP services | `list consumed mcp services [in module];` | Also `show consumed mcp services` | | Describe MCP service | `describe consumed mcp service Module.Name;` | Full MDL output | | Create MCP service | `create [or modify] consumed mcp service Module.Name (ProtocolVersion: v2025_03_26, version: '1.0', ConnectionTimeoutSeconds: 30, documentation: 'text');` | OR MODIFY updates existing service, preserves UUID | -| Drop MCP service | `drop consumed mcp service Module.Name;` | | +| Drop MCP service | `drop consumed mcp service [if exists] Module.Name;` | | **Agent** @@ -1025,7 +1029,7 @@ the `AgentEditorCommons` marketplace module and Mendix 11.9+. | Describe agent | `describe agent Module.Name;` | Full MDL output, re-executable | | Create agent | See example below | Requires a Model document | | Create or modify | `create or modify agent Module.Name (...) { ... };` | Updates existing agent, preserves UUID | -| Drop agent | `drop agent Module.Name;` | Drop agents before their Model/KB/MCP dependencies | +| Drop agent | `drop agent [if exists] Module.Name;` | Drop agents before their Model/KB/MCP dependencies | ```sql create agent Module.MyAgent ( @@ -1075,7 +1079,7 @@ Respond in {{Language}}.$$, | Describe collection | `describe image collection Module.Name;` | Full MDL output with embedded images | | Create collection | `create image collection Module.Name [folder 'path'] [export level 'Hidden'\|'Public'] [comment 'text'] [(image Name from file 'path', ...)];` | With or without images | | Create or modify | `create or modify image collection Module.Name [...];` | Preserves UUID — preferred for AI agents | -| Drop collection | `drop image collection Module.Name;` | Removes collection and all embedded images | +| Drop collection | `drop image collection [if exists] Module.Name;` | Removes collection and all embedded images | | Show an image on a page | `image imgLogo (Image: 'Module.Collection.ImageName');` | Three-part name, like an icon reference. `describe image collection` lists the names | An `image` widget's default source **is** an image collection entry, so a bare @@ -1124,7 +1128,7 @@ Icon collections (`CustomIcons$CustomIconCollection`, e.g. `Atlas_Core.Atlas_Fil | Describe client | `describe rest client Module.Name;` | Re-executable CREATE | | Create client | See syntax below | Property-based `{}` syntax | | Create or modify | `create or modify rest client ...` | Replaces existing | -| Drop client | `drop rest client Module.Name;` | | +| Drop client | `drop rest client [if exists] Module.Name;` | | | Import from OpenAPI | See OpenAPI import below | Auto-generate from spec | | Preview OpenAPI | `describe contract operation from openapi 'path';` | Preview without writing | @@ -1204,7 +1208,7 @@ Operations, path/query parameters, headers, request body, response type, resourc | Alter service | `alter published rest service Module.Name set path = '...', version = '...';` | SET supports Path, Version, ServiceName | | Add resource | `alter published rest service Module.Name add resource 'name' { ... };` | Operation block uses CREATE syntax | | Drop resource | `alter published rest service Module.Name drop resource 'name';` | | -| Drop service | `drop published rest service Module.Name;` | | +| Drop service | `drop published rest service [if exists] Module.Name;` | | | Grant access | `grant access on published rest service Module.Name to Module.Role, ...;` | Adds module roles to AllowedRoles | | Revoke access | `revoke access on published rest service Module.Name from Module.Role, ...;` | | @@ -1245,7 +1249,7 @@ Requires Mendix 11.9+. Steps: `jslt`, `xslt`. Single-line: `jslt '...'`. Multi-l | Describe transformer | `describe data transformer Module.Name;` | Re-executable CREATE | | Create transformer | See syntax below | | | Create or modify | `create or modify data transformer Module.Name ...;` | Updates existing transformer, preserves UUID | -| Drop transformer | `drop data transformer Module.Name;` | | +| Drop transformer | `drop data transformer [if exists] Module.Name;` | | ```sql create data transformer Module.WeatherTransform @@ -1276,7 +1280,7 @@ source json '{"latitude": 51.9, "current": {"temp": 12.8}}' | Alter a definition's members | `alter message definition M.Coll.Def add\|drop\|set member X [in path] [as 'Y']` | Addressed as Module.Collection.Definition; `set` changes only the exposed name | | Alter a collection | `alter message definition collection M.Coll add\|drop\|rename definition ...` | | | Browse | `show message definition collections [in M]`, `describe message definition collection M.Name` | | -| Drop structure | `drop json structure Module.Name;` | | +| Drop structure | `drop json structure [if exists] Module.Name;` | | ## Import Mappings @@ -1287,7 +1291,7 @@ source json '{"latitude": 51.9, "current": {"temp": 12.8}}' | Create mapping | See below | Assignment syntax: `attr = jsonField`, or `attr = a/b/c` to reach a nested leaf with **no entity per level** — the shape Studio Pro produces. The path may not cross a `0..*` element (CE0256) | | Create or modify | `create or modify import mapping Module.Name ...;` | Updates existing mapping, preserves UUID | | Place in a folder | `create [or modify] import mapping Module.Name folder 'path' ...;` | Clause goes after the name. On `or modify` it **moves** the mapping; omitting it leaves placement alone | -| Drop mapping | `drop import mapping Module.Name;` | | +| Drop mapping | `drop import mapping [if exists] Module.Name;` | | | Schema source | `with json structure Module.JSON_X` / `with message definition Module.Collection.Definition` / `with xml schema Module.Schema` | A **message definition** is derived from the domain model rather than a payload sample, so its members are the definition's exposed names and the reference is **three parts** — the definitions live inside a collection document. Read-only: map over one that already exists | | Nested schema root | `with json structure Module.JSON_X root choices/message` | Starts the mapping at a nested element instead of the structure's root. Written in member names; the path may pass through an array, and the mapping is then rooted at the item | | Array-rooted structure | no special syntax | The root is taken from the structure, so `[{...}]` and `{...}` are written the same way | @@ -1351,7 +1355,7 @@ create Module.OrderResponse_CustomerInfo/Module.CustomerInfo = customer { | Create mapping | See below | Assignment syntax: `jsonField = attr`. **No nested `a/b/c` form**: an export has to produce the intermediate node, so Mendix rejects a collapsed member with CE5015 — give it its own element | | Create or modify | `create or modify export mapping Module.Name ...;` | Updates existing mapping, preserves UUID | | Place in a folder | `create [or modify] export mapping Module.Name folder 'path' ...;` | Clause goes after the name. On `or modify` it **moves** the mapping; omitting it leaves placement alone | -| Drop mapping | `drop export mapping Module.Name;` | | +| Drop mapping | `drop export mapping [if exists] Module.Name;` | | ```sql create export mapping Module.EMM_Pet @@ -1388,14 +1392,14 @@ Module.OrderResponse_CustomerInfo/Module.CustomerInfo as customer { | Clear one bitmap | `... exposed as 'c' in 'C' drop icon dark as $$ ... $$;` | `drop icon\|image [dark]` clears exactly one; the others are untouched | | Rename Java action | `rename java action Module.Old to New;` | Renames BSON unit and .java source file | | Rename Java action (dry run) | `rename java action Module.Old to New dry run;` | Preview reference changes without modifying | -| Drop Java action | `drop java action Module.Name;` | Deletes MPR unit and .java source file | +| Drop Java action | `drop java action [if exists] Module.Name;` | Deletes MPR unit and .java source file | | Call from microflow | `$Result = call java action Module.Name(Param = value);` | Inside BEGIN...END | | Empty argument | `call java action Module.Name(Param = empty);` | Unbound code-action parameter preserved as empty mapping | | Show JavaScript actions | `show javascript actions [in module];` | List all or filtered by module | | Describe JavaScript action | `describe javascript action Module.Name;` | Re-executable MDL with signature + body | | Create JavaScript action | `create [or modify] javascript action Module.Name [folder 'path'](params) returns type [platform Web] as $$ ... $$;` | Writes the unit + `javascriptsource//actions/.js`; OR MODIFY preserves UUID | | Create exposed/native | `... exposed as 'caption' in 'Category' platform Native as $$ ... $$;` | `platform` is Web (default), Native, Hybrid, or All | -| Drop JavaScript action | `drop javascript action Module.Name;` | Deletes MPR unit and .js source file | +| Drop JavaScript action | `drop javascript action [if exists] Module.Name;` | Deletes MPR unit and .js source file | | Call from nanoflow | `$Result = call javascript action Module.Name(Param = value);` | Inside a nanoflow | **`AS $$ ... $$` is mandatory** — the body cannot be omitted. Omitting it causes `no viable alternative at input '...'`. Use `as $$ return false; $$;` as a stub. @@ -1467,7 +1471,7 @@ MDL uses explicit property declarations for pages: | List layouts | `show layouts [in module];` | | | Describe layout | `describe layout Module.Name;` | Round-trippable MDL — describe an Atlas layout, rename it, run it to get a copy in your own module | | Create layout | `create [or replace] layout Module.Name ( layouttype: 'X' ) { };` | modelsdk engine only. Refused in a Marketplace module: an update replaces the module and the edit is gone | -| Drop layout | `drop layout Module.Name;` | Pages still bound to it are named in a warning and the drop proceeds; left dropped they fail **CE1613**, which names the *page* | +| Drop layout | `drop layout [if exists] Module.Name;` | Pages still bound to it are named in a warning and the drop proceeds; left dropped they fail **CE1613**, which names the *page* | | Declare a placeholder | `placeholder Main` | **No body.** Exactly one must be named `Main` — mxbuild enforces it (**CE0848**/**CE0849**), and names must be unique (**CE0495**). `placeholder X { … }` is the page-side form and declares nothing (MDL083) | | Alter layout | `alter layout Module.Name { };` | Edits the stored document, so widgets MDL cannot spell survive. Refused for a Marketplace target | | Set a design property | `alter page Module.Page { set 'Row size' = 'Small' on lvOrders; };` | An Atlas design property of that widget's **type** — quoted, case-sensitive; `show design properties for ` lists them. `on`/`off` for a toggle, where `off` removes the entry. Same document `alter styling` writes. A **multi-select** (`Hide on`) or **compound** (`Spacing`) property needs the inline `DesignProperties: [...]` form, since a `set` assignment carries one value | @@ -1484,6 +1488,7 @@ MDL uses explicit property declarations for pages: | Placeholder | `placeholder Main` | The slot a page's content goes into. The name is API — a page binds as `Module.Layout.`. Name one `Main`: that is how Mendix picks the main placeholder (`Forms$Layout` has no property for it). At least one is required | | Navigation tree | `navigationtree name (profile: 'Responsive')` | The sidebar menu (vertical); the profile is a navigation profile name | | Menu bar | `menubar name (profile: 'Responsive')` | The topbar menu (horizontal); same stored shape as a navigation tree | +| Simple menu bar | `simplemenubar name (menu: Module.Menu [, orientation: Horizontal\|Vertical])` | A phone layout's bottom bar (Atlas `Phone_BottomBar`). Any menu widget takes `menu: Module.Menu` (a menu document) **or** `profile: '…'`, never both | | Region as ALTER target | `.` | A region has no name — its slot is its identity. `INSERT INTO layoutContainer.top { … }`. Only `INSERT INTO`; use a widget name for `BEFORE`/`AFTER` | **Snippets & Building Blocks (read-only discovery):** @@ -1496,10 +1501,10 @@ MDL uses explicit property declarations for pages: | Describe building block | `describe building block Module.Name;` | Informational (header comment + widget tree), not a `create` statement | | Create menu | `create [or modify] menu Module.Name [folder 'path'] ( );` | Standalone `Menus$MenuDocument`. Full replacement: the item list is the document's complete contents | | Describe menu | `describe menu Module.Name;` | Round-trippable MDL. Not the navigation-profile menu — see `show navigation menu` | -| Drop menu | `drop menu Module.Name;` | | +| Drop menu | `drop menu [if exists] Module.Name;` | | | Create menu | `create [or modify] menu Module.Name [folder 'path'] ( );` | Standalone `Menus$MenuDocument`. Full replacement: the item list is the document's complete contents | | Describe menu | `describe menu Module.Name;` | Round-trippable MDL. Not the navigation-profile menu — see `show navigation menu` | -| Drop menu | `drop menu Module.Name;` | | +| Drop menu | `drop menu [if exists] Module.Name;` | | **DataGrid Column Properties:** diff --git a/mdl-examples/bug-tests/1173-alter-view-entity-attribute.mdl b/mdl-examples/bug-tests/1173-alter-view-entity-attribute.mdl new file mode 100644 index 000000000..0cf3581ac --- /dev/null +++ b/mdl-examples/bug-tests/1173-alter-view-entity-attribute.mdl @@ -0,0 +1,50 @@ +-- ============================================================================ +-- mendixlabs/mxcli#1173: ALTER ENTITY ... ADD ATTRIBUTE on a view entity +-- ============================================================================ +-- +-- Symptom (before fix): "ALTER ENTITY ... ADD ATTRIBUTE on a view entity writes a +-- stored attribute (CE6770), and check does not catch it". `mxcli check -p +-- --references` passed, exec printed "Added attribute 'Region' to entity +-- MyFirstModule.SaleStats" with exit 0, and `mx check` then reported +-- [CE6770] "View Entity is out of sync with the OQL Query." DROP ATTRIBUTE on a +-- view entity gave the same error (measured on 11.12.1). +-- +-- After fix: check and exec both refuse ADD/DROP ATTRIBUTE on a view entity and +-- point at `create or modify view entity`, which changes the query and the +-- attribute list together — the form below, which builds with 0 errors. +-- +-- Rejected (references mode, against a project that already has the view): +-- alter entity MyFirstModule.SaleStats add attribute Region: String(200); +-- alter entity MyFirstModule.SaleStats drop attribute Total; +-- Still accepted: rename attribute (the column is bound by alias, not by name). +-- ============================================================================ + +create entity MyFirstModule.Sale ( + Amount: Integer, + CustomerName: String(200) +); + +create view entity MyFirstModule.SaleStats ( + CustomerName: String(200), + Total: Integer +) as ( + select + s.CustomerName as CustomerName, + sum(s.Amount) as Total + from MyFirstModule.Sale as s + group by s.CustomerName +); + +-- The supported way to add a column: restate the view with the new column. +create or modify view entity MyFirstModule.SaleStats ( + CustomerName: String(200), + Total: Integer, + Orders: Integer +) as ( + select + s.CustomerName as CustomerName, + sum(s.Amount) as Total, + count(s.Amount) as Orders + from MyFirstModule.Sale as s + group by s.CustomerName +); diff --git a/mdl-examples/bug-tests/1174-oql-keyword-source-alias.mdl b/mdl-examples/bug-tests/1174-oql-keyword-source-alias.mdl new file mode 100644 index 000000000..96aa1f433 --- /dev/null +++ b/mdl-examples/bug-tests/1174-oql-keyword-source-alias.mdl @@ -0,0 +1,33 @@ +-- Bug test for upstream issue #1174: an OQL source alias that is an MDL +-- keyword did not parse, so DESCRIBE output could not be fed back to exec. +-- +-- Reported on v0.23.0 / Mendix 11.12.1: +-- +-- line 7:62 mismatched input 'ROLE' expecting IDENTIFIER +-- line 9:0 mismatched input ')' expecting {SELECT, HAVING} +-- +-- ROLE is reserved in MDL but not in OQL, so `AS ROLE` is legal in the model +-- (mx check 0 errors) and DESCRIBE prints it verbatim. The grammar now takes +-- any keyword as a FROM / JOIN / derived-table alias after an explicit AS, and +-- as the leading alias of an association path. Without AS it stays +-- IDENTIFIER-only, so `from M.Sale s left join …` keeps LEFT as the join. +-- +-- Expected: `mxcli check` passes; exec creates the view; `mx check` reports +-- 0 errors; DESCRIBE prints `AS ROLE` and exec'ing that text changes nothing. + +create persistent entity MyFirstModule.Sale1174 ( Amount: Integer ); + +create association MyFirstModule.Sale1174_UserRole + from MyFirstModule.Sale1174 to System.UserRole; + +create view entity MyFirstModule.SaleStats1174 ( + RoleName: String(100), + Total: Integer +) as ( + SELECT + ROLE.Name as RoleName, + SUM(s.Amount) as Total + FROM MyFirstModule.Sale1174 as s + LEFT JOIN s/MyFirstModule.Sale1174_UserRole/System.UserRole AS ROLE + GROUP BY ROLE.Name +); diff --git a/mdl-examples/bug-tests/1175-oql-comment-is-not-a-select-column.mdl b/mdl-examples/bug-tests/1175-oql-comment-is-not-a-select-column.mdl new file mode 100644 index 000000000..f91db6125 --- /dev/null +++ b/mdl-examples/bug-tests/1175-oql-comment-is-not-a-select-column.mdl @@ -0,0 +1,29 @@ +-- Bug test for upstream issue #1175: a `--` comment inside a view entity's +-- select list was read as a select column. +-- +-- Reported on v0.23.0: +-- +-- select column 1 has no as alias: '-- the customer's running total' +-- +-- plus a second false MDL030 for the text after the comment's comma. Neither +-- the apostrophe nor the comma is the cause — any comment in the select list +-- was split into the column list — but together they make the reported text. +-- The OQL checks now blank comments out before reading the query; the stored +-- query keeps them. +-- +-- Expected: `mxcli check` passes with no MDL030, and DESCRIBE shows the +-- comments still in the query. + +create persistent entity MyFirstModule.Sale1175 ( Amount: Integer ); + +create view entity MyFirstModule.SaleStats1175 ( + Total: Integer, + SaleCount: Integer +) as ( + select + -- the customer's running total, summed here rather than on the page + sum(s.Amount) as Total, + /* the number of sales, from the table */ + count(s.Amount) as SaleCount + from MyFirstModule.Sale1175 as s +); diff --git a/mdl-examples/bug-tests/531-drop-if-exists.mdl b/mdl-examples/bug-tests/531-drop-if-exists.mdl new file mode 100644 index 000000000..9ed659cc8 --- /dev/null +++ b/mdl-examples/bug-tests/531-drop-if-exists.mdl @@ -0,0 +1,34 @@ +-- Bug: no document-level DROP accepted IF EXISTS, so a script that dropped +-- anything could not be re-run. ako/mxcli#531, from the ChipCoV3 test project +-- (Mendix 11.14.0), where a stub page that broke a workflow/page cycle had to +-- be dropped once and the statement then deleted from the script: +-- +-- DROP PAGE IF EXISTS FieldService.Stub; +-- line 1:13 extraneous input 'EXISTS' expecting the start of a statement +-- +-- Expected: `mxcli check` passes; `mxcli exec` runs this script TWICE against the +-- same project with no error — the first run drops what exists, the second +-- reports each drop as "not found …, skipped (if exists)". A missing module is +-- a skip too. Without IF EXISTS, the second run stops at the first drop. + +create or modify module M531; + +create or modify persistent entity M531.Scratch ( Name: string(50) ); +create or modify enumeration M531.OldStatus ( Open 'Open' ); +create or modify constant M531.OldLimit type integer default 5; +create or modify microflow M531.ACT_Old () begin log 'old'; end; + +-- the drops a replayable script would carry after the documents above are gone +drop microflow if exists M531.ACT_Old; +drop constant if exists M531.OldLimit; +drop enumeration if exists M531.OldStatus; +drop entity if exists M531.Scratch; +drop page if exists M531.Stub; +drop snippet if exists M531.NeverThere; +drop workflow if exists M531.NeverThere; +drop folder if exists 'Scratch' in M531; +drop module if exists M531_NeverThere; + +-- a second pass is the re-run: every target is already gone +drop microflow if exists M531.ACT_Old; +drop entity if exists M531.Scratch; diff --git a/mdl-examples/bug-tests/572-use-fragment-in-alter-page.mdl b/mdl-examples/bug-tests/572-use-fragment-in-alter-page.mdl new file mode 100644 index 000000000..f8f16c29e --- /dev/null +++ b/mdl-examples/bug-tests/572-use-fragment-in-alter-page.mdl @@ -0,0 +1,56 @@ +-- Bug #572: `use fragment` inside ALTER PAGE … INSERT / REPLACE passed `check` +-- and failed in exec with +-- "failed to build widget SaveCancelFooter: unsupported widget type: USE_FRAGMENT". +-- Fragments were expanded only on the CREATE PAGE path; ALTER PAGE handed the +-- USE_FRAGMENT sentinel straight to the widget builder. +-- +-- Fix: applyInsertWidgetMutator / applyReplaceWidgetMutator expand fragments +-- (and building blocks) before the duplicate-name check and the build, at any +-- depth of the inserted tree. +-- +-- Verify: mxcli check passes; exec inserts ctnFooter into dvRequest, and the +-- REPLACE swaps txtNote's container for the fragment's. + +create module FragAlter; + +@position(100,100) +create persistent entity FragAlter.ServiceRequest ( + Title: string(100), + Note: string(200) +); + +define fragment SaveCancelFooter as { + footer ctnFooter { + actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary) + actionbutton btnCancel (caption: 'Cancel', action: cancel_changes) + } +}; + +create page FragAlter.ServiceRequest_NewEdit +( + params: { $ServiceRequest: FragAlter.ServiceRequest }, + title: 'Service Request', + layout: Atlas_Core.PopupLayout +) +{ + dataview dvRequest (datasource: $ServiceRequest) { + textbox txtTitle (label: 'Title', attribute: Title) + container ctnNote { + textbox txtNote (label: 'Note', attribute: Note) + } + } +}; + +-- INSERT INTO with a fragment (the reported shape). +alter page FragAlter.ServiceRequest_NewEdit { + insert into dvRequest { use fragment SaveCancelFooter as ins_ } +}; + +-- REPLACE with a fragment nested in a container. +alter page FragAlter.ServiceRequest_NewEdit { + replace ctnNote with { + container ctnNoteNew { + use fragment SaveCancelFooter as rep_ + } + } +}; diff --git a/mdl-examples/bug-tests/573-simple-menu-bar.mdl b/mdl-examples/bug-tests/573-simple-menu-bar.mdl new file mode 100644 index 000000000..30829c64b --- /dev/null +++ b/mdl-examples/bug-tests/573-simple-menu-bar.mdl @@ -0,0 +1,62 @@ +-- ============================================================================ +-- ako/mxcli#573 — Forms$SimpleMenuBar is authorable +-- ============================================================================ +-- +-- Atlas's phone layouts carry their bottom bar as a Forms$SimpleMenuBar that +-- renders a MENU DOCUMENT. mxcli could neither author one nor describe it: +-- +-- region bottom (Class: 'region-bottombar') { +-- -- Forms$SimpleMenuBar (simpleMenuBar1) -- NOT re-executable: mxcli cannot +-- -- author this widget, so re-running this script would drop it +-- } +-- +-- so a phone layout written in MDL had no bottom bar. `simplemenubar` now takes +-- `Menu: Module.Menu` (a menu document) or `Profile: '...'` (a navigation +-- profile), and `Orientation: Horizontal | Vertical`. Menu bars and navigation +-- trees take `Menu:` the same way — it is the same MenuSource slot. +-- +-- Run: mxcli exec mdl-examples/bug-tests/573-simple-menu-bar.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe layout Bug573.Phone_Bottom" +-- ============================================================================ + +create module Bug573; + +create or modify menu Bug573.Bottom_Menu ( + menu item 'Home' icon Atlas_Core.Atlas.home; + menu item 'Agenda' icon Atlas_Core.Atlas.calendar; + menu item 'Profile' icon Atlas_Core.Atlas.user; +); + +create or replace layout Bug573.Phone_Bottom ( + layouttype: 'Phone', + class: 'layout-atlas layout-atlas-phone' +) { + scrollcontainer scrollContainer1 { + region bottom (class: 'region-bottombar') { + simplemenubar bottomBar (menu: Bug573.Bottom_Menu, class: 'bottom-nav-text-icons') + } + region center (class: 'region-content') { + placeholder Main + } + } +} + +-- The other source, the other orientation, and the two sibling menu widgets +-- pointed at a menu document. +create or replace layout Bug573.Responsive_Menus ( + layouttype: 'Responsive', + class: 'layout-atlas layout-atlas-responsive-default' +) { + scrollcontainer layoutContainer { + region top (size: 60, sizemode: 'Fixed', class: 'region-topbar') { + menubar topMenu (menu: Bug573.Bottom_Menu) + } + region left (size: 232, sizemode: 'Pixels', class: 'region-sidebar') { + navigationtree sideMenu (menu: Bug573.Bottom_Menu) + simplemenubar sideBar (profile: 'Responsive', orientation: Vertical) + } + region center (class: 'region-content') { + placeholder Main + } + } +} diff --git a/mdl-examples/bug-tests/576-datasource-bare-entity-shorthand.mdl b/mdl-examples/bug-tests/576-datasource-bare-entity-shorthand.mdl new file mode 100644 index 000000000..88f203a7d --- /dev/null +++ b/mdl-examples/bug-tests/576-datasource-bare-entity-shorthand.mdl @@ -0,0 +1,48 @@ +-- Bug test: `DataSource: Module.Entity` on a data grid was silently dropped. +-- Issue ako/mxcli#576. +-- +-- The bare-entity shorthand matched no dataSourceExprV3 alternative, fell +-- through to the generic `keyword: propertyValueV3` branch, and was stored as a +-- plain string that nothing read. `check` passed, `exec` printed "Created page", +-- `describe page` showed the grid with no source, and mxbuild reported CE0488 +-- "No entity configured for the data source of this widgets container", plus +-- CE1571 for the row action's $currentObject argument. +-- +-- The shorthand now means DATABASE Module.Entity (on a data view that is refused +-- by name, MDL-WIDGET09, rather than dropped). +-- +-- Run with: mxcli exec 576-datasource-bare-entity-shorthand.mdl -p app.mpr +-- then: describe page bug576.Shorthand +-- expect: datagrid dgShort (DataSource: DATABASE bug576.Car, ...) +-- and `mxcli docker check` reports no CE0488 / CE1571 on dgShort. +create module bug576; + +create persistent entity bug576.Car ( + Name: string(200) +); + +create page bug576.Detail (Title: 'Car', Layout: Atlas_Core.Atlas_Default, Params: { $Car: bug576.Car }) { + dataview dvCar (DataSource: $Car) { + textbox txtName (Label: 'Name', Attribute: Name) + } +}; + +create page bug576.Shorthand (Title: 'Cars', Layout: Atlas_Core.Atlas_Default) { + datagrid dgShort (DataSource: bug576.Car, onClick: SHOW_PAGE bug576.Detail(Car: $currentObject)) { + column colName (Caption: 'Name', Attribute: Name) + } +}; + +-- Control: the explicit spelling, which always worked. +create page bug576.Explicit (Title: 'Cars', Layout: Atlas_Core.Atlas_Default) { + datagrid dgExplicit (DataSource: DATABASE bug576.Car, onClick: SHOW_PAGE bug576.Detail(Car: $currentObject)) { + column colName (Caption: 'Name', Attribute: Name) + } +}; + +-- Scope: the shorthand applies to the built-in datagrid / listview / gallery / +-- dataview keywords only. A pluggable widget's `datasource:` is its own .mpk key +-- (the Barcode Scanner's is an ATTRIBUTE: `datasource: Module.Entity.Code`) and +-- keeps its meaning. `alter page … set DataSource = Module.Entity` was never a +-- silent drop — it refuses with "DataSource value must be a datasource +-- expression" — and is unchanged. diff --git a/mdl-examples/bug-tests/653-view-entity-oql-indent-drift.mdl b/mdl-examples/bug-tests/653-view-entity-oql-indent-drift.mdl new file mode 100644 index 000000000..8edf3f0d4 --- /dev/null +++ b/mdl-examples/bug-tests/653-view-entity-oql-indent-drift.mdl @@ -0,0 +1,36 @@ +-- ============================================================================ +-- ako/mxcli#653: describe → exec of a view entity adds 2 spaces of indentation +-- to the stored OQL on every cycle +-- ============================================================================ +-- +-- Symptom (before fix): "Each cycle reports `Modified view entity` and stores +-- the query with every line after the first indented two spaces further." +-- Describe indents every stored line by two spaces; exec stored the source text +-- from the query's first token, so line 1 lost its indentation and lines 2…n +-- kept all of it. +-- +-- After fix: exec strips the indentation common to the query's lines (line 1 +-- counted at its column in the script), so the query below is stored as +-- select +-- sum(s.Amount) as Total +-- from MyFirstModule.Sale653 as s +-- and `describe entity MyFirstModule.Ind`, exec'd back, reports +-- `Unchanged view entity` on every cycle, not just the first. +-- +-- Verify: +-- mxcli exec 653-view-entity-oql-indent-drift.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe entity MyFirstModule.Ind" > d.mdl +-- mxcli exec d.mdl -p app.mpr # repeat describe + exec: Unchanged each time +-- ============================================================================ + +create persistent entity MyFirstModule.Sale653 ( + Amount: Decimal +); + +create view entity MyFirstModule.Ind ( + Total: Decimal +) as ( + select + sum(s.Amount) as Total + from MyFirstModule.Sale653 as s +); diff --git a/mdl-examples/bug-tests/maint2-editable-never-create-page.mdl b/mdl-examples/bug-tests/maint2-editable-never-create-page.mdl index c610fde28..b629a9ade 100644 --- a/mdl-examples/bug-tests/maint2-editable-never-create-page.mdl +++ b/mdl-examples/bug-tests/maint2-editable-never-create-page.mdl @@ -27,12 +27,16 @@ create entity if not exists MaintTwo.WorkOrder ( create or replace page MaintTwo.WorkOrder_View ( Title: 'Work order', - Layout: Atlas_Core.Atlas_Default + Layout: Atlas_Core.Atlas_Default, + Params: { $WorkOrder: MaintTwo.WorkOrder } ) { layoutgrid lg { row r1 { column c1 (DesktopWidth: AutoFill) { - dataview dv (Datasource: MaintTwo.WorkOrder) { + -- A page parameter, not `Datasource: MaintTwo.WorkOrder`: that shorthand was + -- silently dropped (ako/mxcli#576) and left this data view unbound; it now + -- means DATABASE, which a data view refuses (MDL-WIDGET09). + dataview dv (Datasource: $WorkOrder) { -- Read-only: these persist as Editable "Never". textbox txtNumber (Attribute: Number, Editable: Never) textarea txtNotes (Attribute: Notes, Editable: Never) diff --git a/mdl-examples/bug-tests/pages-552-list-widget-row-action.mdl b/mdl-examples/bug-tests/pages-552-list-widget-row-action.mdl index 216b1b69f..2e5a03198 100644 --- a/mdl-examples/bug-tests/pages-552-list-widget-row-action.mdl +++ b/mdl-examples/bug-tests/pages-552-list-widget-row-action.mdl @@ -46,8 +46,7 @@ create page Mod.List (Title: 'Cars', Layout: Atlas_Core.Atlas_Default) { }; -- NOT a case for this file: the bare-entity shorthand `DataSource: Mod.Car` on a --- data grid is SILENTLY DROPPED (the stored widget has no data source at all), --- so mxbuild reports CE0488 and then a real CE1571. The guard still stands down --- on it — it refuses only what it can prove is discarded, and a source it cannot --- read is unknown, not absent — but that case belongs to the shorthand bug, not --- here. Measured on mxbuild 11.14.0; see ako/mxcli#576. +-- data grid was SILENTLY DROPPED until ako/mxcli#576 (mxbuild 11.14.0 reported +-- CE0488 and then a real CE1571). It now binds as DATABASE Mod.Car, so the row +-- context is known; mdl-examples/bug-tests/576-datasource-bare-entity-shorthand.mdl +-- covers it. diff --git a/mdl-examples/doctype-tests/layouts.mdl b/mdl-examples/doctype-tests/layouts.mdl index 78c24dfb4..06d2e253b 100644 --- a/mdl-examples/doctype-tests/layouts.mdl +++ b/mdl-examples/doctype-tests/layouts.mdl @@ -146,6 +146,31 @@ alter layout LayoutTest.App_Default { drop widget tagline; }; +-- ---------------------------------------------------------------------------- +-- A phone layout's bottom bar: a SIMPLEMENUBAR rendering a menu document, the +-- shape Atlas_Core.Phone_BottomBar has. Any menu widget takes `menu:` (a menu +-- document) or `profile:` (a navigation profile), never both. +-- ---------------------------------------------------------------------------- + +create or modify menu LayoutTest.Phone_Menu ( + menu item 'Home' icon Atlas_Core.Atlas.home; + menu item 'Account' icon Atlas_Core.Atlas.user; +); + +create or replace layout LayoutTest.Phone_Bottom ( + layouttype: 'Phone', + class: 'layout-atlas layout-atlas-phone' +) { + scrollcontainer scrollContainer1 { + region bottom (class: 'region-bottombar') { + simplemenubar bottomBar (menu: LayoutTest.Phone_Menu, class: 'bottom-nav-text-icons') + } + region center (class: 'region-content') { + placeholder Main + } + } +} + -- ---------------------------------------------------------------------------- -- DESCRIBE emits re-executable MDL, which is how a layout is copied: describe -- an Atlas layout, change the qualified name, run it. diff --git a/mdl/ast/ast_agenteditor.go b/mdl/ast/ast_agenteditor.go index 4eb228170..9fe3bc84c 100644 --- a/mdl/ast/ast_agenteditor.go +++ b/mdl/ast/ast_agenteditor.go @@ -35,6 +35,7 @@ func (s *CreateModelStmt) isStatement() {} // DropModelStmt represents: DROP MODEL Module.Name type DropModelStmt struct { + DropGuard Name QualifiedName } @@ -72,6 +73,7 @@ func (s *CreateConsumedMCPServiceStmt) isStatement() {} // DropConsumedMCPServiceStmt represents: DROP CONSUMED MCP SERVICE Module.Name type DropConsumedMCPServiceStmt struct { + DropGuard Name QualifiedName } @@ -112,6 +114,7 @@ func (s *CreateKnowledgeBaseStmt) isStatement() {} // DropKnowledgeBaseStmt represents: DROP KNOWLEDGE BASE Module.Name type DropKnowledgeBaseStmt struct { + DropGuard Name QualifiedName } @@ -152,6 +155,7 @@ func (s *CreateAgentStmt) isStatement() {} // DropAgentStmt represents: DROP AGENT Module.Name type DropAgentStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_association.go b/mdl/ast/ast_association.go index 0dfa7e8c9..ff6554803 100644 --- a/mdl/ast/ast_association.go +++ b/mdl/ast/ast_association.go @@ -131,6 +131,7 @@ func (s *CreateAssociationStmt) isStatement() {} // DropAssociationStmt represents: DROP ASSOCIATION Module.Name type DropAssociationStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_businessevents.go b/mdl/ast/ast_businessevents.go index 4ab0071ad..d1978e163 100644 --- a/mdl/ast/ast_businessevents.go +++ b/mdl/ast/ast_businessevents.go @@ -33,6 +33,7 @@ type BusinessEventAttributeDef struct { // DropBusinessEventServiceStmt represents DROP BUSINESS EVENT SERVICE. type DropBusinessEventServiceStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_datatransformer.go b/mdl/ast/ast_datatransformer.go index 4b7cd504f..9e8d2dabe 100644 --- a/mdl/ast/ast_datatransformer.go +++ b/mdl/ast/ast_datatransformer.go @@ -24,6 +24,7 @@ type DataTransformerStepDef struct { // DropDataTransformerStmt represents: DROP DATA TRANSFORMER Module.Name type DropDataTransformerStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_drop.go b/mdl/ast/ast_drop.go new file mode 100644 index 000000000..2c120cfb8 --- /dev/null +++ b/mdl/ast/ast_drop.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 + +package ast + +// DropGuard is embedded in every document-level DROP statement. IfExists +// downgrades "not found" to a skip, so a script that drops something — a stub +// page that broke a cycle, a renamed document's old name — can be re-run. +// +// The guard is honoured once, in the executor's dispatch, rather than in each +// handler: there are some thirty drop handlers, and a guard that has to be +// remembered per handler is the one the next doctype forgets (#531). +type DropGuard struct { + IfExists bool +} + +// DropIfExists reports whether the statement was written DROP … IF EXISTS. +func (g *DropGuard) DropIfExists() bool { return g.IfExists } + +// SetDropIfExists records the guard; the visitor calls it once for whichever +// drop statement it built. +func (g *DropGuard) SetDropIfExists(v bool) { g.IfExists = v } + +// IfExistsDrop is implemented by every statement that embeds DropGuard. +type IfExistsDrop interface { + Statement + DropIfExists() bool + SetDropIfExists(bool) +} diff --git a/mdl/ast/ast_entity.go b/mdl/ast/ast_entity.go index 2e2ef405e..4f7524ef9 100644 --- a/mdl/ast/ast_entity.go +++ b/mdl/ast/ast_entity.go @@ -57,6 +57,7 @@ func (s *CreateEntityStmt) isStatement() {} // DropEntityStmt represents: DROP ENTITY Module.Name type DropEntityStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_enumeration.go b/mdl/ast/ast_enumeration.go index e46c780a5..94d2d5ede 100644 --- a/mdl/ast/ast_enumeration.go +++ b/mdl/ast/ast_enumeration.go @@ -22,6 +22,7 @@ func (s *CreateModuleStmt) isStatement() {} // DropModuleStmt represents: DROP MODULE ModuleName type DropModuleStmt struct { + DropGuard Name string } @@ -29,6 +30,7 @@ func (s *DropModuleStmt) isStatement() {} // DropFolderStmt represents: DROP FOLDER 'path' IN Module type DropFolderStmt struct { + DropGuard FolderPath string // Folder path (e.g., "Resources/Images") Module string // Module name } @@ -90,6 +92,7 @@ const ( // DropEnumerationStmt represents: DROP ENUMERATION Module.Name type DropEnumerationStmt struct { + DropGuard Name QualifiedName } @@ -116,6 +119,7 @@ func (s *CreateConstantStmt) isStatement() {} // DropConstantStmt represents: DROP CONSTANT Module.Name type DropConstantStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_imagecollection.go b/mdl/ast/ast_imagecollection.go index 41451cfcb..15d229df7 100644 --- a/mdl/ast/ast_imagecollection.go +++ b/mdl/ast/ast_imagecollection.go @@ -25,6 +25,7 @@ func (s *CreateImageCollectionStmt) isStatement() {} // DropImageCollectionStmt represents: DROP IMAGE COLLECTION Module.Name type DropImageCollectionStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_import_export_mapping.go b/mdl/ast/ast_import_export_mapping.go index c8afaf67c..976376b63 100644 --- a/mdl/ast/ast_import_export_mapping.go +++ b/mdl/ast/ast_import_export_mapping.go @@ -36,6 +36,7 @@ func (s *CreateImportMappingStmt) isStatement() {} // DropImportMappingStmt represents: DROP IMPORT MAPPING Module.Name type DropImportMappingStmt struct { + DropGuard Name QualifiedName } @@ -113,6 +114,7 @@ func (s *CreateExportMappingStmt) isStatement() {} // DropExportMappingStmt represents: DROP EXPORT MAPPING Module.Name type DropExportMappingStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_javaaction.go b/mdl/ast/ast_javaaction.go index 16677a3b5..7c4f5191d 100644 --- a/mdl/ast/ast_javaaction.go +++ b/mdl/ast/ast_javaaction.go @@ -46,6 +46,7 @@ func (s *CreateJavaActionStmt) isStatement() {} // DropJavaActionStmt represents: DROP JAVA ACTION Module.Name type DropJavaActionStmt struct { + DropGuard Name QualifiedName } @@ -86,6 +87,7 @@ func (s *CreateJavaScriptActionStmt) isStatement() {} // DropJavaScriptActionStmt represents: DROP JAVASCRIPT ACTION Module.Name type DropJavaScriptActionStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_jsonstructure.go b/mdl/ast/ast_jsonstructure.go index 68ee75a2c..617366ce0 100644 --- a/mdl/ast/ast_jsonstructure.go +++ b/mdl/ast/ast_jsonstructure.go @@ -24,6 +24,7 @@ func (s *CreateJsonStructureStmt) isStatement() {} // DropJsonStructureStmt represents: DROP JSON STRUCTURE Module.Name type DropJsonStructureStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_messagedefinition.go b/mdl/ast/ast_messagedefinition.go index dde6cd3d0..7daa36ab1 100644 --- a/mdl/ast/ast_messagedefinition.go +++ b/mdl/ast/ast_messagedefinition.go @@ -69,6 +69,7 @@ func (m *MessageMemberDef) IsAssociation() bool { return m.Association.Name != " // DropMessageDefinitionCollectionStmt represents: // DROP MESSAGE DEFINITION COLLECTION Module.Name type DropMessageDefinitionCollectionStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_microflow.go b/mdl/ast/ast_microflow.go index 35bd0f0ad..277f7c5f8 100644 --- a/mdl/ast/ast_microflow.go +++ b/mdl/ast/ast_microflow.go @@ -145,6 +145,7 @@ func (s *CreateMicroflowStmt) isStatement() {} // DropMicroflowStmt represents: DROP MICROFLOW Module.Name type DropMicroflowStmt struct { + DropGuard Name QualifiedName } @@ -206,6 +207,7 @@ func (s *CreateRuleStmt) isStatement() {} // DropRuleStmt represents: DROP RULE Module.Name type DropRuleStmt struct { + DropGuard Name QualifiedName } @@ -213,6 +215,7 @@ func (s *DropRuleStmt) isStatement() {} // DropNanoflowStmt represents: DROP NANOFLOW Module.Name type DropNanoflowStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_navigation.go b/mdl/ast/ast_navigation.go index 352508efd..53791655c 100644 --- a/mdl/ast/ast_navigation.go +++ b/mdl/ast/ast_navigation.go @@ -85,6 +85,7 @@ func (s *CreateMenuStmt) isStatement() {} // DropMenuStmt is `drop menu Module.Name`. type DropMenuStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_odata.go b/mdl/ast/ast_odata.go index 9123dbd00..2e19e4cad 100644 --- a/mdl/ast/ast_odata.go +++ b/mdl/ast/ast_odata.go @@ -78,6 +78,7 @@ func (s *AlterODataClientStmt) isStatement() {} // DropODataClientStmt represents: DROP ODATA CLIENT Module.Name type DropODataClientStmt struct { + DropGuard Name QualifiedName } @@ -196,6 +197,7 @@ func (s *AlterODataServiceStmt) isStatement() {} // DropODataServiceStmt represents: DROP ODATA SERVICE Module.Name type DropODataServiceStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_page.go b/mdl/ast/ast_page.go index 65bdd58cc..f8ad54c09 100644 --- a/mdl/ast/ast_page.go +++ b/mdl/ast/ast_page.go @@ -45,6 +45,7 @@ type DataGridColumnDef struct { // DropPageStmt represents: DROP PAGE Module.Name type DropPageStmt struct { + DropGuard Name QualifiedName } @@ -56,6 +57,7 @@ func (s *DropPageStmt) isStatement() {} // a layout written by mistake — the CE0848 shape in mendixlabs/mxcli#1063 was // exactly that — had no headless remedy at all. type DropLayoutStmt struct { + DropGuard Name QualifiedName } @@ -63,6 +65,7 @@ func (s *DropLayoutStmt) isStatement() {} // DropSnippetStmt represents: DROP SNIPPET Module.Name type DropSnippetStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_queue.go b/mdl/ast/ast_queue.go index 97bf44be2..a4f4ee199 100644 --- a/mdl/ast/ast_queue.go +++ b/mdl/ast/ast_queue.go @@ -24,6 +24,7 @@ func (s *CreateQueueStmt) isStatement() {} // DropQueueStmt represents: DROP QUEUE Module.Name; type DropQueueStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_regularexpression.go b/mdl/ast/ast_regularexpression.go index 2327efe13..eb01475d7 100644 --- a/mdl/ast/ast_regularexpression.go +++ b/mdl/ast/ast_regularexpression.go @@ -23,6 +23,7 @@ func (s *CreateRegularExpressionStmt) isStatement() {} // DropRegularExpressionStmt represents: DROP REGULAR EXPRESSION Module.Name; type DropRegularExpressionStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_rest.go b/mdl/ast/ast_rest.go index 27dfc146e..5b9d930df 100644 --- a/mdl/ast/ast_rest.go +++ b/mdl/ast/ast_rest.go @@ -82,6 +82,7 @@ type RestHeaderDef struct { // DropRestClientStmt represents: DROP REST CLIENT Module.Name type DropRestClientStmt struct { + DropGuard Name QualifiedName } @@ -131,6 +132,7 @@ type PublishedRestOperationDef struct { // DropPublishedRestServiceStmt represents: DROP PUBLISHED REST SERVICE Module.Name type DropPublishedRestServiceStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_scheduledevent.go b/mdl/ast/ast_scheduledevent.go index 6b5a853a8..296d27e61 100644 --- a/mdl/ast/ast_scheduledevent.go +++ b/mdl/ast/ast_scheduledevent.go @@ -57,6 +57,7 @@ func (s *CreateScheduledEventStmt) isStatement() {} // DropScheduledEventStmt represents: DROP SCHEDULED EVENT Module.Name; type DropScheduledEventStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/ast/ast_settings.go b/mdl/ast/ast_settings.go index 8728c8fe7..5333e1b55 100644 --- a/mdl/ast/ast_settings.go +++ b/mdl/ast/ast_settings.go @@ -56,6 +56,7 @@ func (s *CreateConfigurationStmt) isStatement() {} // DropConfigurationStmt represents DROP CONFIGURATION 'name'. type DropConfigurationStmt struct { + DropGuard Name string } diff --git a/mdl/ast/ast_workflow.go b/mdl/ast/ast_workflow.go index 62f5716fa..5bc82a952 100644 --- a/mdl/ast/ast_workflow.go +++ b/mdl/ast/ast_workflow.go @@ -60,6 +60,7 @@ func (s *CreateWorkflowStmt) isStatement() {} // DropWorkflowStmt represents: DROP WORKFLOW Module.Name type DropWorkflowStmt struct { + DropGuard Name QualifiedName } diff --git a/mdl/backend/modelsdk/widget_layout_write_test.go b/mdl/backend/modelsdk/widget_layout_write_test.go index 3be960723..556fd61a3 100644 --- a/mdl/backend/modelsdk/widget_layout_write_test.go +++ b/mdl/backend/modelsdk/widget_layout_write_test.go @@ -161,3 +161,82 @@ func TestMenuBarToGen_WrapsTheProfileInAMenuSource(t *testing.T) { } } } + +// ako/mxcli#573: Atlas_Core.Phone_BottomBar's bottom bar is a +// Forms$SimpleMenuBar, and mxcli could not author one — so a phone layout +// written in MDL had no bottom bar at all. +// +// The shape is measured on that widget in a blank 11.14.0 project: Appearance, +// MenuSource, Name, Orientation, TabIndex, with the menu document named inside a +// Forms$MenuDocumentSource — not on the bar, and not as a navigation profile. +func TestSimpleMenuBarToGen_WrapsTheMenuInAMenuDocumentSource(t *testing.T) { + g, err := widgetToGen(&pages.SimpleMenuBar{ + BaseWidget: pages.BaseWidget{Name: "simpleMenuBar1"}, + Menu: "Atlas_Core.Phone_Menu", + Orientation: pages.MenuOrientationHorizontal, + }) + if err != nil { + t.Fatal(err) + } + doc := encodeToMap(t, g) + if doc["$Type"] != "Forms$SimpleMenuBar" { + t.Fatalf("$Type = %v", doc["$Type"]) + } + src, ok := doc["MenuSource"].(map[string]any) + if !ok { + t.Fatalf("MenuSource missing or not a document; keys = %v", keysOf(doc)) + } + if src["$Type"] != "Forms$MenuDocumentSource" { + t.Errorf("MenuSource $Type = %v, want Forms$MenuDocumentSource", src["$Type"]) + } + if src["Menu"] != "Atlas_Core.Phone_Menu" { + t.Errorf("Menu = %v, want Atlas_Core.Phone_Menu", src["Menu"]) + } + if doc["Orientation"] != "Horizontal" { + t.Errorf("Orientation = %v, want Horizontal", doc["Orientation"]) + } + for _, k := range keysOf(doc) { + switch k { + case "$ID", "$Type", "Appearance", "MenuSource", "Name", "Orientation", "TabIndex": + default: + t.Errorf("wrote %q, which Forms$SimpleMenuBar does not have", k) + } + } +} + +// Every menu widget takes its items from either a navigation profile or a menu +// document; the two are different MenuSource subtypes, and naming both is a +// contradiction rather than a preference order. +func TestMenuWidgetToGen_MenuSourceKind(t *testing.T) { + cases := []struct { + name string + w pages.Widget + wantType string + wantKey string + wantVal string + }{ + {"simple bar, profile", &pages.SimpleMenuBar{BaseWidget: pages.BaseWidget{Name: "b"}, NavigationProfile: "Phone"}, + "Forms$NavigationSource", "NavigationProfile", "Phone"}, + {"menu bar, document", &pages.MenuBar{BaseWidget: pages.BaseWidget{Name: "b"}, Menu: "M.Top"}, + "Forms$MenuDocumentSource", "Menu", "M.Top"}, + {"tree, document", &pages.NavigationTree{BaseWidget: pages.BaseWidget{Name: "t"}, Menu: "M.Side"}, + "Forms$MenuDocumentSource", "Menu", "M.Side"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + g, err := widgetToGen(c.w) + if err != nil { + t.Fatal(err) + } + src, _ := encodeToMap(t, g)["MenuSource"].(map[string]any) + if src["$Type"] != c.wantType || src[c.wantKey] != c.wantVal { + t.Errorf("MenuSource = %v, want %s{%s: %s}", src, c.wantType, c.wantKey, c.wantVal) + } + }) + } + + _, err := widgetToGen(&pages.SimpleMenuBar{BaseWidget: pages.BaseWidget{Name: "b"}, Menu: "M.X", NavigationProfile: "Phone"}) + if err == nil { + t.Error("a menu widget naming both a menu document and a profile was accepted") + } +} diff --git a/mdl/backend/modelsdk/widget_write.go b/mdl/backend/modelsdk/widget_write.go index 15fa85c3e..bd297391e 100644 --- a/mdl/backend/modelsdk/widget_write.go +++ b/mdl/backend/modelsdk/widget_write.go @@ -644,9 +644,10 @@ func widgetToGen(w pages.Widget) (element.Element, error) { case *pages.NavigationTree: g := genPg.NewNavigationTree() applyWidgetBase(g, &x.BaseWidget) - src := genPg.NewNavigationSource() - assignID(src) - src.SetNavigationProfileQualifiedName(orDefaultStr(x.NavigationProfile, "Responsive")) + src, err := menuSourceToGen(x.Name, x.Menu, x.NavigationProfile) + if err != nil { + return nil, err + } g.SetMenuSource(src) return g, nil @@ -655,10 +656,24 @@ func widgetToGen(w pages.Widget) (element.Element, error) { // a menu bar is the horizontal navigation a topbar carries. g := genPg.NewMenuBar() applyWidgetBase(g, &x.BaseWidget) - src := genPg.NewNavigationSource() - assignID(src) - src.SetNavigationProfileQualifiedName(orDefaultStr(x.NavigationProfile, "Responsive")) + src, err := menuSourceToGen(x.Name, x.Menu, x.NavigationProfile) + if err != nil { + return nil, err + } + g.SetMenuSource(src) + return g, nil + + case *pages.SimpleMenuBar: + // The bottom bar of Atlas's phone layouts (ako/mxcli#573). A menu bar's + // keys plus Orientation, measured on Atlas_Core.Phone_BottomBar. + g := genPg.NewSimpleMenuBar() + applyWidgetBase(g, &x.BaseWidget) + src, err := menuSourceToGen(x.Name, x.Menu, x.NavigationProfile) + if err != nil { + return nil, err + } g.SetMenuSource(src) + g.SetOrientation(orDefaultStr(string(x.Orientation), string(pages.MenuOrientationHorizontal))) return g, nil case *pages.GroupBox: @@ -1880,3 +1895,23 @@ func scrollRegionToGen(r *pages.ScrollContainerRegion) (element.Element, error) } return g, nil } + +// menuSourceToGen builds the MenuSource every menu widget carries: a +// Forms$MenuDocumentSource when it renders a menu document, otherwise a +// Forms$NavigationSource naming a profile (Responsive when none is given). +// The two are alternatives for one slot, so both set is an error. +func menuSourceToGen(widget, menu, profile string) (element.Element, error) { + if menu != "" { + if profile != "" { + return nil, fmt.Errorf("menu widget %q names both menu document %q and navigation profile %q; it renders one", widget, menu, profile) + } + src := genPg.NewMenuDocumentSource() + assignID(src) + src.SetMenuQualifiedName(menu) + return src, nil + } + src := genPg.NewNavigationSource() + assignID(src) + src.SetNavigationProfileQualifiedName(orDefaultStr(profile, "Responsive")) + return src, nil +} diff --git a/mdl/executor/alter_entity_view_test.go b/mdl/executor/alter_entity_view_test.go new file mode 100644 index 000000000..a2222c156 --- /dev/null +++ b/mdl/executor/alter_entity_view_test.go @@ -0,0 +1,154 @@ +// 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/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// viewAlterTestCtx builds an ExecContext over Shop.SaleStats (a view entity, as +// mxcli and Studio Pro store one) and Shop.Sale (a persistent entity), wired so +// both execAlterEntity and the check-time validator can resolve them. +func viewAlterTestCtx(t *testing.T) (*ExecContext, *bool) { + t.Helper() + mod := mkModule("Shop") + dmID := nextID("dm") + stats := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: nextID("ent")}, + ContainerID: dmID, + Name: "SaleStats", + Persistable: true, + Source: "DomainModels$OqlViewEntitySource", + OqlQuery: "select s.CustomerName as CustomerName, sum(s.Amount) as Total from Shop.Sale as s group by s.CustomerName", + Attributes: []*domainmodel.Attribute{ + {Name: "CustomerName"}, + {Name: "Total"}, + }, + } + sale := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: nextID("ent")}, + ContainerID: dmID, + Name: "Sale", + Persistable: true, + Attributes: []*domainmodel.Attribute{{Name: "Amount"}, {Name: "CustomerName"}}, + } + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: dmID}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{stats, sale}, + } + h := mkHierarchy(mod) + withContainer(h, dm.ID, mod.ID) + + updated := false + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + ListDomainModelsFunc: func() ([]*domainmodel.DomainModel, error) { return []*domainmodel.DomainModel{dm}, nil }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { return dm, nil }, + UpdateEntityFunc: func(dmID model.ID, e *domainmodel.Entity) error { updated = true; return nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx, &updated +} + +func viewAlterStmts(entity string) map[string]*ast.AlterEntityStmt { + qn := ast.QualifiedName{Module: "Shop", Name: entity} + return map[string]*ast.AlterEntityStmt{ + "add": { + Name: qn, Operation: ast.AlterEntityAddAttribute, + Attribute: &ast.Attribute{Name: "Region", Type: ast.DataType{Kind: ast.TypeString, Length: 200}}, + }, + "drop": {Name: qn, Operation: ast.AlterEntityDropAttribute, AttributeName: "CustomerName"}, + } +} + +// mendixlabs/mxcli#1173: ADD ATTRIBUTE on a view entity wrote a +// DomainModels$StoredValue attribute with no OQL column behind it, reported +// "Added attribute 'Region' to entity …" with exit 0, and Studio Pro then +// refused the model with CE6770 "View Entity is out of sync with the OQL +// Query." DROP ATTRIBUTE leaves a query column with no attribute, the same +// error measured on 11.12.1. Both must be refused before anything is written. +func TestAlterEntity_RefusesAttributeSetChangeOnViewEntity(t *testing.T) { + for op, stmt := range viewAlterStmts("SaleStats") { + t.Run(op, func(t *testing.T) { + ctx, updated := viewAlterTestCtx(t) + err := execAlterEntity(ctx, stmt) + if err == nil { + t.Fatalf("%s attribute on a view entity was accepted — mxbuild reports CE6770", op) + } + if *updated { + t.Error("the entity was written despite the refusal") + } + msg := err.Error() + for _, want := range []string{"view entity", "create or modify view entity", "CE6770"} { + if !strings.Contains(msg, want) { + t.Errorf("refusal does not mention %q:\n%s", want, msg) + } + } + }) + } +} + +// The same refusal at check time: `mxcli check -p --references` passed the +// reported script, so the error surfaced only after exec had written it. +func TestValidateAlterEntity_RefusesAttributeSetChangeOnViewEntity(t *testing.T) { + for op, stmt := range viewAlterStmts("SaleStats") { + t.Run(op, func(t *testing.T) { + ctx, _ := viewAlterTestCtx(t) + if err := validateWithContext(ctx, stmt, newScriptContext()); err == nil { + t.Fatalf("check passed %s attribute on a stored view entity", op) + } + }) + } + t.Run("view entity created by the script", func(t *testing.T) { + ctx, _ := viewAlterTestCtx(t) + sc := newScriptContext() + sc.entities["Shop.Fresh"] = true + sc.viewEntities["Shop.Fresh"] = true + if err := validateWithContext(ctx, viewAlterStmts("Fresh")["add"], sc); err == nil { + t.Fatal("check passed ADD ATTRIBUTE on a view entity the script creates") + } + }) +} + +// CONTROL: the same statements on a persistent entity still go through, and +// RENAME ATTRIBUTE on a view entity — measured clean on 11.12.1, because the +// OqlViewValue binds the column by its Reference, not by the attribute name — +// is not caught by the refusal. +func TestAlterEntity_ViewRefusalLeavesOtherAltersAlone(t *testing.T) { + for op, stmt := range viewAlterStmts("Sale") { + t.Run("persistent "+op, func(t *testing.T) { + ctx, updated := viewAlterTestCtx(t) + if err := validateWithContext(ctx, stmt, newScriptContext()); err != nil { + t.Fatalf("check refused %s attribute on a persistent entity: %v", op, err) + } + if op == "drop" { + return // DROP consults the catalog for references; the check-time path is what matters here + } + if err := execAlterEntity(ctx, stmt); err != nil { + t.Fatalf("exec refused %s attribute on a persistent entity: %v", op, err) + } + if !*updated { + t.Error("expected the persistent entity to be written") + } + }) + } + t.Run("rename on view", func(t *testing.T) { + ctx, _ := viewAlterTestCtx(t) + stmt := &ast.AlterEntityStmt{ + Name: ast.QualifiedName{Module: "Shop", Name: "SaleStats"}, + Operation: ast.AlterEntityRenameAttribute, + AttributeName: "Total", NewName: "GrandTotal", + } + if err := validateWithContext(ctx, stmt, newScriptContext()); err != nil { + t.Fatalf("check refused RENAME ATTRIBUTE on a view entity: %v", err) + } + }) +} diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index a15536ff2..2af79fd09 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -292,6 +292,14 @@ func convertASTAction(ctx *ExecContext, value any, moduleName string, moduleID m // ============================================================================ func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op *ast.InsertWidgetOp, moduleName string, moduleID model.ID) error { + opWidgets, err := expandAlterFragments(ctx, op.Widgets, moduleName, moduleID) + if err != nil { + return err + } + expanded := *op + expanded.Widgets = opWidgets + op = &expanded + // Check for duplicate widget names before building for _, w := range op.Widgets { if w.Name != "" && mutator.FindWidget(w.Name) { @@ -364,6 +372,24 @@ func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op return mutator.InsertWidget(op.Target.Widget, op.Target.Column, backend.InsertPosition(op.Position), widgets) } +// expandAlterFragments expands `use fragment` / `use building block` sentinels +// in the widgets an INSERT or REPLACE carries, the same expansion CREATE PAGE +// applies to its body. It runs before anything else looks at the widgets, so +// the duplicate-name check, the column/template routing and the builder all see +// the fragment's widgets rather than the sentinel (#572). The input is cloned: +// expansion rewrites Children in place and the statement's AST is not ours. +func expandAlterFragments(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName string, moduleID model.ID) ([]*ast.WidgetV3, error) { + pb := &pageBuilder{ + ctx: ctx, + backend: ctx.Backend, + moduleID: moduleID, + moduleName: moduleName, + execCache: ctx.Cache, + fragments: ctx.Fragments, + } + return pb.expandFragments(cloneWidgets(widgets)) +} + // ============================================================================ // DROP widget via mutator // ============================================================================ @@ -381,6 +407,14 @@ func applyDropWidgetMutator(mutator backend.PageMutator, op *ast.DropWidgetOp) e // ============================================================================ func applyReplaceWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op *ast.ReplaceWidgetOp, moduleName string, moduleID model.ID) error { + newWidgets, err := expandAlterFragments(ctx, op.NewWidgets, moduleName, moduleID) + if err != nil { + return err + } + expanded := *op + expanded.NewWidgets = newWidgets + op = &expanded + // Check for duplicate widget names (skip the widget being replaced) for _, w := range op.NewWidgets { if w.Name != "" && w.Name != op.Target.Widget && w.Name != op.Target.Column && mutator.FindWidget(w.Name) { diff --git a/mdl/executor/cmd_alter_page_fragment_test.go b/mdl/executor/cmd_alter_page_fragment_test.go new file mode 100644 index 000000000..6e1effca0 --- /dev/null +++ b/mdl/executor/cmd_alter_page_fragment_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// Issue #572: `use fragment` inside ALTER PAGE … INSERT / REPLACE failed in exec +// with "failed to build widget SaveCancelFooter: unsupported widget type: +// USE_FRAGMENT" — fragments were expanded only on the CREATE PAGE path. + +func withFragments(frags ...*ast.DefineFragmentStmt) mockCtxOption { + return func(ctx *ExecContext) { + ctx.Fragments = map[string]*ast.DefineFragmentStmt{} + for _, f := range frags { + ctx.Fragments[f.Name] = f + } + } +} + +func saveCancelFooterFragment() *ast.DefineFragmentStmt { + return &ast.DefineFragmentStmt{ + Name: "SaveCancelFooter", + Widgets: []*ast.WidgetV3{{ + Type: "container", Name: "ctnFooter", Properties: map[string]any{}, + Children: []*ast.WidgetV3{{ + Type: "dynamictext", Name: "lblFooter", Properties: map[string]any{"Content": "x"}, + }}, + }}, + } +} + +func useFragment(name string) *ast.WidgetV3 { + return &ast.WidgetV3{Type: "USE_FRAGMENT", Name: name, Properties: map[string]any{}} +} + +func widgetNames(ws []pages.Widget) []string { + var out []string + for _, w := range ws { + out = append(out, w.GetName()) + } + return out +} + +func TestAlterPageInsertExpandsFragment(t *testing.T) { + var got []pages.Widget + mutator := &mock.MockPageMutator{ + EnclosingEntityForChildrenFunc: func(string) string { return "" }, + InsertWidgetFunc: func(_ string, _ string, _ backend.InsertPosition, ws []pages.Widget) error { + got = ws + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "frm_dataView7"}, + Widgets: []*ast.WidgetV3{useFragment("SaveCancelFooter")}, + }, withFragments(saveCancelFooterFragment())) + if err != nil { + t.Fatalf("insert: %v", err) + } + if names := widgetNames(got); len(names) != 1 || names[0] != "ctnFooter" { + t.Fatalf("inserted widgets = %v, want [ctnFooter]", names) + } +} + +// A fragment nested inside an inserted container is expanded too. +func TestAlterPageInsertExpandsNestedFragment(t *testing.T) { + var got []pages.Widget + mutator := &mock.MockPageMutator{ + EnclosingEntityFunc: func(string) string { return "" }, + InsertWidgetFunc: func(_ string, _ string, _ backend.InsertPosition, ws []pages.Widget) error { + got = ws + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "AFTER", + Target: ast.WidgetRef{Widget: "txtName"}, + Widgets: []*ast.WidgetV3{{ + Type: "container", Name: "ctnOuter", Properties: map[string]any{}, + Children: []*ast.WidgetV3{useFragment("SaveCancelFooter")}, + }}, + }, withFragments(saveCancelFooterFragment())) + if err != nil { + t.Fatalf("insert: %v", err) + } + if len(got) != 1 { + t.Fatalf("inserted %d widgets, want 1", len(got)) + } + outer, ok := got[0].(*pages.Container) + if !ok { + t.Fatalf("inserted %T, want *pages.Container", got[0]) + } + if names := widgetNames(outer.Widgets); len(names) != 1 || names[0] != "ctnFooter" { + t.Fatalf("outer children = %v, want [ctnFooter]", names) + } +} + +func TestAlterPageReplaceExpandsFragment(t *testing.T) { + var got []pages.Widget + mutator := &mock.MockPageMutator{ + EnclosingEntityFunc: func(string) string { return "" }, + ReplaceWidgetFunc: func(_ string, _ string, ws []pages.Widget) error { + got = ws + return nil + }, + } + err := alterPageWith(t, mutator, &ast.ReplaceWidgetOp{ + Target: ast.WidgetRef{Widget: "ctnOldFooter"}, + NewWidgets: []*ast.WidgetV3{useFragment("SaveCancelFooter")}, + }, withFragments(saveCancelFooterFragment())) + if err != nil { + t.Fatalf("replace: %v", err) + } + if names := widgetNames(got); len(names) != 1 || names[0] != "ctnFooter" { + t.Fatalf("replacement widgets = %v, want [ctnFooter]", names) + } +} + +// The duplicate-name check must see the fragment's widgets, not the sentinel — +// otherwise a fragment that re-inserts an existing name slips past it. +func TestAlterPageInsertFragmentDuplicateName(t *testing.T) { + mutator := &mock.MockPageMutator{ + FindWidgetFunc: func(name string) bool { return name == "ctnFooter" }, + EnclosingEntityForChildrenFunc: func(string) string { return "" }, + InsertWidgetFunc: func(string, string, backend.InsertPosition, []pages.Widget) error { + return nil + }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "frm_dataView7"}, + Widgets: []*ast.WidgetV3{useFragment("SaveCancelFooter")}, + }, withFragments(saveCancelFooterFragment())) + if err == nil || !strings.Contains(err.Error(), "ctnFooter") { + t.Fatalf("err = %v, want a duplicate-name error naming ctnFooter", err) + } +} + +func TestAlterPageInsertUndefinedFragment(t *testing.T) { + mutator := &mock.MockPageMutator{ + EnclosingEntityForChildrenFunc: func(string) string { return "" }, + } + err := alterPageWith(t, mutator, &ast.InsertWidgetOp{ + Position: "INTO", + Target: ast.WidgetRef{Widget: "frm_dataView7"}, + Widgets: []*ast.WidgetV3{useFragment("Nope")}, + }) + if err == nil || strings.Contains(err.Error(), "unsupported widget type") || !strings.Contains(err.Error(), "Nope") { + t.Fatalf("err = %v, want a not-found error naming the fragment", err) + } +} diff --git a/mdl/executor/cmd_alter_page_listview_template_test.go b/mdl/executor/cmd_alter_page_listview_template_test.go index cc7373c14..e3a4358c1 100644 --- a/mdl/executor/cmd_alter_page_listview_template_test.go +++ b/mdl/executor/cmd_alter_page_listview_template_test.go @@ -14,7 +14,7 @@ import ( ) // alterPageWith runs one ALTER PAGE operation against a mock mutator. -func alterPageWith(t *testing.T, mutator *mock.MockPageMutator, op ast.AlterPageOperation) error { +func alterPageWith(t *testing.T, mutator *mock.MockPageMutator, op ast.AlterPageOperation, opts ...mockCtxOption) error { t.Helper() mod := mkModule("MyModule") pg := mkPage(mod.ID, "TestPage") @@ -42,7 +42,7 @@ func alterPageWith(t *testing.T, mutator *mock.MockPageMutator, op ast.AlterPage } h := mkHierarchy(mod) withContainer(h, pg.ContainerID, mod.ID) - ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + ctx, _ := newMockCtx(t, append([]mockCtxOption{withBackend(mb), withHierarchy(h)}, opts...)...) return execAlterPage(ctx, &ast.AlterPageStmt{ PageName: ast.QualifiedName{Module: "MyModule", Name: "TestPage"}, Operations: []ast.AlterPageOperation{op}, diff --git a/mdl/executor/cmd_entities.go b/mdl/executor/cmd_entities.go index 1db904341..e2936f07e 100644 --- a/mdl/executor/cmd_entities.go +++ b/mdl/executor/cmd_entities.go @@ -810,6 +810,26 @@ func isViewEntity(e *domainmodel.Entity) bool { e.OqlQuery != "" || e.SourceDocumentRef != "" } +// viewEntityAttributeSetRefusal refuses an ALTER ENTITY that adds or drops an +// attribute of a view entity. A view entity's attributes are the columns of its +// OQL query, each an OqlViewValue bound to a select alias, so changing the set +// on its own leaves the entity out of sync with the query: ADD wrote a +// StoredValue attribute with no column behind it and DROP left a column with no +// attribute, both CE6770 "View Entity is out of sync with the OQL Query." on +// 11.12.1 (mendixlabs/mxcli#1173). The query is the declaration, so the change +// belongs in the statement that carries it. RENAME is not refused: the column is +// bound by the value's Reference, not the attribute name, and it builds clean. +func viewEntityAttributeSetRefusal(entityQN, verb, attrName string) error { + return mdlerrors.NewValidationf( + "cannot %s attribute '%s' on %s: it is a view entity, whose attributes are the "+ + "columns of its OQL query — changing them on their own gives CE6770 "+ + "\"View Entity is out of sync with the OQL Query.\"\n"+ + " Change the query and the attribute list together with "+ + "'create or modify view entity %s (…) as (select …)'; "+ + "'describe entity %s' prints the current definition to start from", + verb, attrName, entityQN, entityQN, entityQN) +} + // droppedEntityMembers reports the members present on existing but absent from // replacement — i.e. what a CREATE OR MODIFY replace would delete. Named // attributes are compared case-insensitively; the four audit system fields and @@ -1069,6 +1089,9 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { if a == nil { return mdlerrors.NewValidation("no attribute definition provided") } + if isViewEntity(entity) { + return viewEntityAttributeSetRefusal(s.Name.String(), "add", a.Name) + } // Pseudo-types: set entity flags instead of adding real attributes switch a.Type.Kind { case ast.TypeAutoOwner: @@ -1336,6 +1359,9 @@ func execAlterEntity(ctx *ExecContext, s *ast.AlterEntityStmt) error { ctx.ReportMutation("Modified", "attribute '%s' on entity %s", s.AttributeName, s.Name) case ast.AlterEntityDropAttribute: + if isViewEntity(entity) { + return viewEntityAttributeSetRefusal(s.Name.String(), "drop", s.AttributeName) + } // System attribute pseudo-names: drop by clearing entity flags switch strings.ToLower(s.AttributeName) { case "owner": diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index 10652c860..b93b31a95 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -419,6 +419,8 @@ func (pb *pageBuilder) buildWidgetV3(w *ast.WidgetV3) (pages.Widget, error) { widget, err = pb.buildNavigationTreeV3(w) case "menubar": widget, err = pb.buildMenuBarV3(w) + case "simplemenubar": + widget, err = pb.buildSimpleMenuBarV3(w) case "placeholder": widget, err = pb.buildPlaceholderV3(w) case "radiobuttons": @@ -2670,13 +2672,14 @@ func cloneWidgets(widgets []*ast.WidgetV3) []*ast.WidgetV3 { return result } +// cloneWidget copies the whole struct first, so a field added to WidgetV3 is +// carried without touching this function (Specialization and TypeIsGeneric were +// once dropped here); only the map and the children need a deep copy. func cloneWidget(w *ast.WidgetV3) *ast.WidgetV3 { - clone := &ast.WidgetV3{ - Type: w.Type, - Name: w.Name, - Properties: make(map[string]interface{}, len(w.Properties)), - Children: cloneWidgets(w.Children), - } + c := *w + clone := &c + clone.Properties = make(map[string]interface{}, len(w.Properties)) + clone.Children = cloneWidgets(w.Children) for k, v := range w.Properties { clone.Properties[k] = v // Property values are immutable (strings, ints, etc.) } @@ -2794,7 +2797,11 @@ func (pb *pageBuilder) buildNavigationTreeV3(w *ast.WidgetV3) (pages.Widget, err }, Name: w.Name, }, - NavigationProfile: w.GetStringProp("Profile"), + } + var err error + nt.Menu, nt.NavigationProfile, err = menuSourceV3(w) + if err != nil { + return nil, err } return nt, nil } @@ -2822,7 +2829,7 @@ func (pb *pageBuilder) buildPlaceholderV3(w *ast.WidgetV3) (pages.Widget, error) // buildMenuBarV3 builds the horizontal navigation a topbar carries. Same shape // as a navigation tree — see widget_write.go. func (pb *pageBuilder) buildMenuBarV3(w *ast.WidgetV3) (pages.Widget, error) { - return &pages.MenuBar{ + mb := &pages.MenuBar{ BaseWidget: pages.BaseWidget{ BaseElement: model.BaseElement{ ID: model.ID(types.GenerateID()), @@ -2830,8 +2837,57 @@ func (pb *pageBuilder) buildMenuBarV3(w *ast.WidgetV3) (pages.Widget, error) { }, Name: w.Name, }, - NavigationProfile: w.GetStringProp("Profile"), - }, nil + } + var err error + mb.Menu, mb.NavigationProfile, err = menuSourceV3(w) + if err != nil { + return nil, err + } + return mb, nil +} + +// buildSimpleMenuBarV3 builds the bar Atlas's phone layouts carry in their +// bottom region (ako/mxcli#573). Unlike a menu bar it has an orientation, and +// the one Atlas ships renders a menu document rather than a profile. +func (pb *pageBuilder) buildSimpleMenuBarV3(w *ast.WidgetV3) (pages.Widget, error) { + sb := &pages.SimpleMenuBar{ + BaseWidget: pages.BaseWidget{ + BaseElement: model.BaseElement{ + ID: model.ID(types.GenerateID()), + TypeName: "Forms$SimpleMenuBar", + }, + Name: w.Name, + }, + Orientation: pages.MenuOrientationHorizontal, + } + switch o := w.GetStringProp("Orientation"); strings.ToLower(o) { + case "", "horizontal": + case "vertical": + sb.Orientation = pages.MenuOrientationVertical + default: + return nil, mdlerrors.NewValidationf("simplemenubar %s: Orientation must be Horizontal or Vertical, got %q", w.Name, o) + } + var err error + sb.Menu, sb.NavigationProfile, err = menuSourceV3(w) + if err != nil { + return nil, err + } + return sb, nil +} + +// menuSourceV3 reads which items a menu widget renders: a menu document +// (`Menu: Module.Menu`, stored as a Forms$MenuDocumentSource) or a navigation +// profile (`Profile: 'Phone'`, a Forms$NavigationSource). They are two subtypes +// of one MenuSource slot, so naming both is refused rather than resolved by a +// precedence nobody wrote down. Neither leaves both empty, and the writer +// defaults to the Responsive profile. +func menuSourceV3(w *ast.WidgetV3) (menu, profile string, err error) { + menu = w.GetStringProp("Menu") + profile = w.GetStringProp("Profile") + if menu != "" && profile != "" { + return "", "", mdlerrors.NewValidationf("%s %s: give either Menu: (a menu document) or Profile: (a navigation profile), not both", strings.ToLower(w.Type), w.Name) + } + return menu, profile, nil } // missingWidgetMessage explains why a widget has no definition, and — the part diff --git a/mdl/executor/cmd_pages_describe.go b/mdl/executor/cmd_pages_describe.go index 2d604adcf..b008a2222 100644 --- a/mdl/executor/cmd_pages_describe.go +++ b/mdl/executor/cmd_pages_describe.go @@ -704,6 +704,11 @@ type rawWidget struct { // NavigationProfile is a Forms$NavigationTree's profile, which the document // keeps one level down in MenuSource rather than on the tree. NavigationProfile string + // Menu is the menu document a menu widget renders instead of a profile — a + // Forms$MenuDocumentSource in the same MenuSource slot (ako/mxcli#573). + Menu string + // MenuOrientation is a Forms$SimpleMenuBar's Orientation. + MenuOrientation string // Specialization is the entity a List View template renders. Set only on the // synthetic wrappers parseListViewContent emits for Forms$ListViewTemplate, // which is the same shape as TabCaption above: a container with no name, whose diff --git a/mdl/executor/cmd_pages_describe_output.go b/mdl/executor/cmd_pages_describe_output.go index 8c131d324..c0c5c0b5e 100644 --- a/mdl/executor/cmd_pages_describe_output.go +++ b/mdl/executor/cmd_pages_describe_output.go @@ -304,16 +304,27 @@ func outputWidgetMDLV3(ctx *ExecContext, w rawWidget, indent int) { // — a page binds to it as Module.Layout.. fmt.Fprintf(ctx.Output, "%splaceholder %s\n", prefix, mdlIdent(w.Name)) - case "Forms$NavigationTree", "Pages$NavigationTree", "Forms$MenuBar", "Pages$MenuBar": + case "Forms$NavigationTree", "Pages$NavigationTree", "Forms$MenuBar", "Pages$MenuBar", + "Forms$SimpleMenuBar", "Pages$SimpleMenuBar": keyword := "navigationtree" - if strings.HasSuffix(w.Type, "$MenuBar") { + switch { + case strings.HasSuffix(w.Type, "$SimpleMenuBar"): + keyword = "simplemenubar" + case strings.HasSuffix(w.Type, "$MenuBar"): keyword = "menubar" } header := fmt.Sprintf("%s %s", keyword, mdlIdent(w.Name)) var props []string - if w.NavigationProfile != "" { + if w.Menu != "" { + props = append(props, fmt.Sprintf("Menu: %s", w.Menu)) + } else if w.NavigationProfile != "" { props = append(props, fmt.Sprintf("Profile: %s", mdlQuote(w.NavigationProfile))) } + // Horizontal is the default the builder applies, so only Vertical + // needs saying. + if w.MenuOrientation == "Vertical" { + props = append(props, "Orientation: Vertical") + } props = appendAppearanceProps(props, w) formatWidgetProps(ctx.Output, prefix, header, props, "\n") diff --git a/mdl/executor/cmd_pages_describe_parse.go b/mdl/executor/cmd_pages_describe_parse.go index 98ea47608..817e3d89f 100644 --- a/mdl/executor/cmd_pages_describe_parse.go +++ b/mdl/executor/cmd_pages_describe_parse.go @@ -271,13 +271,21 @@ func parseRawWidget(ctx *ExecContext, w map[string]any, parentEntityContext ...s return []rawWidget{widget} case "Forms$NavigationTree", "Pages$NavigationTree", - "Forms$MenuBar", "Pages$MenuBar": - // The profile is a qualified name one level down, in a - // Forms$NavigationSource, not a property of the tree. + "Forms$MenuBar", "Pages$MenuBar", + "Forms$SimpleMenuBar", "Pages$SimpleMenuBar": + // What the widget renders is one level down, in MenuSource: a profile + // in a Forms$NavigationSource, or a menu document in a + // Forms$MenuDocumentSource — Atlas's phone bottom bar is the latter. if src, ok := w["MenuSource"].(map[string]any); ok { if p, ok := src["NavigationProfile"].(string); ok { widget.NavigationProfile = p } + if m, ok := src["Menu"].(string); ok { + widget.Menu = m + } + } + if o, ok := w["Orientation"].(string); ok { + widget.MenuOrientation = o } return []rawWidget{widget} diff --git a/mdl/executor/cmd_pages_describe_simplemenubar_test.go b/mdl/executor/cmd_pages_describe_simplemenubar_test.go new file mode 100644 index 000000000..1bd78d3c5 --- /dev/null +++ b/mdl/executor/cmd_pages_describe_simplemenubar_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// ako/mxcli#573: `describe layout Atlas_Core.Phone_BottomBar` said +// +// region bottom (Class: 'region-bottombar') { +// -- Forms$SimpleMenuBar (simpleMenuBar1) -- NOT re-executable: mxcli cannot +// -- author this widget, so re-running this script would drop it +// } +// +// so a phone layout could not carry a bottom bar from MDL at all, and a copy of +// Atlas's lost it. The widget renders a MENU DOCUMENT, and which one is the whole +// point of it — emitting the keyword without the reference would turn a visible +// note into a silent drop. + +// storedSimpleMenuBar is the widget exactly as a blank 11.14.0 project stores it +// in Atlas_Core.Phone_BottomBar. +func storedSimpleMenuBar(orientation string, source map[string]any) map[string]any { + return map[string]any{ + "$Type": "Forms$SimpleMenuBar", + "Name": "simpleMenuBar1", + "Appearance": map[string]any{ + "$Type": "Forms$Appearance", + "Class": "bottom-nav-text-icons", + "Style": "", + "DynamicClasses": "", + }, + "MenuSource": source, + "Orientation": orientation, + "TabIndex": int32(0), + } +} + +func rebuildMenuWidget(t *testing.T, widgetMDL string) pages.Widget { + t.Helper() + src := "create page Mod.P (Title: 'T') {\n" + widgetMDL + "\n}" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("DESCRIBE emitted MDL that does not parse (%q): %v", widgetMDL, errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + if len(page.Widgets) == 0 { + t.Fatalf("DESCRIBE emitted no widget, only:\n%s", widgetMDL) + } + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + widget, err := pb.buildWidgetV3(page.Widgets[0]) + if err != nil { + t.Fatalf("building %q: %v", widgetMDL, err) + } + return widget +} + +func TestDescribeSimpleMenuBar_RoundTripsTheMenuDocument(t *testing.T) { + got := describeStoredWidget(t, storedSimpleMenuBar("Horizontal", map[string]any{ + "$Type": "Forms$MenuDocumentSource", + "Menu": "Atlas_Core.Phone_Menu", + })) + if strings.Contains(got, "NOT re-executable") { + t.Fatalf("simple menu bar still described as unauthorable:\n%s", got) + } + for _, want := range []string{"simplemenubar simpleMenuBar1", "Menu: Atlas_Core.Phone_Menu", "Class: 'bottom-nav-text-icons'"} { + if !strings.Contains(got, want) { + t.Errorf("description lacks %q:\n%s", want, got) + } + } + + bar, ok := rebuildMenuWidget(t, got).(*pages.SimpleMenuBar) + if !ok { + t.Fatalf("replay did not build a simple menu bar from:\n%s", got) + } + if bar.Menu != "Atlas_Core.Phone_Menu" { + t.Errorf("replayed Menu = %q, want Atlas_Core.Phone_Menu", bar.Menu) + } + if bar.NavigationProfile != "" { + t.Errorf("replayed NavigationProfile = %q, want none — the source is a menu document", bar.NavigationProfile) + } + if bar.Orientation != pages.MenuOrientationHorizontal { + t.Errorf("replayed Orientation = %q, want Horizontal", bar.Orientation) + } +} + +// Vertical is the non-default, so it is the one a describer that forgets the +// property loses. +func TestDescribeSimpleMenuBar_RoundTripsVerticalAndAProfile(t *testing.T) { + got := describeStoredWidget(t, storedSimpleMenuBar("Vertical", map[string]any{ + "$Type": "Forms$NavigationSource", + "NavigationProfile": "Phone", + })) + bar, ok := rebuildMenuWidget(t, got).(*pages.SimpleMenuBar) + if !ok { + t.Fatalf("replay did not build a simple menu bar from:\n%s", got) + } + if bar.Orientation != pages.MenuOrientationVertical { + t.Errorf("replayed Orientation = %q, want Vertical; described as:\n%s", bar.Orientation, got) + } + if bar.NavigationProfile != "Phone" || bar.Menu != "" { + t.Errorf("replayed source = profile %q / menu %q, want profile Phone; described as:\n%s", bar.NavigationProfile, bar.Menu, got) + } +} + +// A menu bar or navigation tree pointed at a menu document described with its +// profile slot empty — `menubar m` — and replayed onto the Responsive profile. +func TestDescribeMenuBar_KeepsAMenuDocumentSource(t *testing.T) { + got := describeStoredWidget(t, map[string]any{ + "$Type": "Forms$MenuBar", + "Name": "topMenu", + "MenuSource": map[string]any{"$Type": "Forms$MenuDocumentSource", "Menu": "Mod.TopMenu"}, + }) + bar, ok := rebuildMenuWidget(t, got).(*pages.MenuBar) + if !ok { + t.Fatalf("replay did not build a menu bar from:\n%s", got) + } + if bar.Menu != "Mod.TopMenu" || bar.NavigationProfile != "" { + t.Errorf("replayed source = menu %q / profile %q, want menu Mod.TopMenu; described as:\n%s", bar.Menu, bar.NavigationProfile, got) + } +} + +func TestSimpleMenuBar_RejectsBothSourcesAndABadOrientation(t *testing.T) { + for _, src := range []string{ + "simplemenubar b (Menu: Mod.M, Profile: 'Phone')", + "simplemenubar b (Menu: Mod.M, Orientation: Diagonal)", + } { + prog, errs := visitor.Build("create page Mod.P (Title: 'T') {\n" + src + "\n}") + if len(errs) > 0 { + t.Fatalf("%q does not parse: %v", src, errs) + } + pb := &pageBuilder{widgetScope: map[string]model.ID{}} + if _, err := pb.buildWidgetV3(prog.Statements[0].(*ast.CreatePageStmtV3).Widgets[0]); err == nil { + t.Errorf("%q was accepted", src) + } + } +} diff --git a/mdl/executor/cmd_pages_showpage_args.go b/mdl/executor/cmd_pages_showpage_args.go index 6fb3d08ef..847825d50 100644 --- a/mdl/executor/cmd_pages_showpage_args.go +++ b/mdl/executor/cmd_pages_showpage_args.go @@ -81,9 +81,10 @@ func contextVarFor(ds *ast.DataSourceV3) string { // The list is an allow-list on purpose. Concluding "there is no context object // here" is only safe when every widget between the page root and this one is // known to bind nothing, and a widget's data source is not always readable from -// the AST: `datagrid dg (DataSource: Mod.Entity)` — the bare-entity shorthand — -// leaves a plain string rather than a parsed *ast.DataSourceV3, and a pluggable -// widget names its source under its own key. Anything not listed here therefore +// the AST: a pluggable widget names its source under its own key, and a +// DataSource value that is not a parsed *ast.DataSourceV3 cannot be read. (The +// bare-entity shorthand `DataSource: Mod.Entity` was that case until it parsed +// as DATABASE, ako/mxcli#576.) Anything not listed here therefore // degrades the context to UNKNOWN rather than to ABSENT, so a row-scoped button // is never refused (mdl-examples/bug-tests/295-showpage-null-variable.mdl is that // case, and it is exactly what the first cut of #1029 broke). @@ -132,16 +133,17 @@ func argContextForOwnAction(w *ast.WidgetV3, parent pageArgContext) pageArgConte if bindsDataInAnUnreadableShape(w) { // The widget plainly binds data, so a context object EXISTS — but this // pass cannot say what it is called. Unknown, not absent, and the guard - // stands down exactly as it does for ALTER PAGE. The bare-entity - // shorthand `datagrid dg (DataSource: M.E)` is this case. + // stands down exactly as it does for ALTER PAGE. return pageArgContext{} } return parent } // bindsDataInAnUnreadableShape reports whether w names a data source this pass -// cannot parse into a *ast.DataSourceV3 — the bare-entity shorthand, or a -// pluggable widget naming its source under its own key. +// cannot parse into a *ast.DataSourceV3. The visitor no longer produces one +// under `DataSource` (the bare-entity shorthand parses as DATABASE since +// ako/mxcli#576); this stays as the defensive half of the doctrine that the +// guard refuses only what it can prove is discarded. func bindsDataInAnUnreadableShape(w *ast.WidgetV3) bool { for name, v := range w.Properties { if !strings.EqualFold(name, "DataSource") { diff --git a/mdl/executor/cmd_pages_showpage_args_test.go b/mdl/executor/cmd_pages_showpage_args_test.go index 7ee5e0d28..1e288e96c 100644 --- a/mdl/executor/cmd_pages_showpage_args_test.go +++ b/mdl/executor/cmd_pages_showpage_args_test.go @@ -275,8 +275,8 @@ func TestValidateShowPageArguments_ListWidgetOwnRowAction(t *testing.T) { }) } - // The bare-entity shorthand leaves a plain string rather than a parsed source, - // so the entity is unreadable — but the widget plainly binds data, and the + // A DataSource that is not a parsed source (the bare-entity shorthand was one + // until #576 made it parse as DATABASE) leaves the entity unreadable — but the widget plainly binds data, and the // guard's own doctrine is that it refuses only what it can PROVE is discarded. t.Run("bare-entity shorthand stands the guard down", func(t *testing.T) { if got := hits(rowAction("datagrid", "Mod.Car", "$currentObject")); len(got) != 0 { diff --git a/mdl/executor/drop_if_exists_test.go b/mdl/executor/drop_if_exists_test.go new file mode 100644 index 000000000..a4f283433 --- /dev/null +++ b/mdl/executor/drop_if_exists_test.go @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +// dropIfExistsTargets is every document-level DROP. Each one names a document +// that does not exist in a project that does have the module, so the handler +// reaches its own lookup rather than stopping at "module not found". +var dropIfExistsTargets = []string{ + "DROP ENTITY %s MyModule.Gone", + "DROP ASSOCIATION %s MyModule.Gone", + "DROP ENUMERATION %s MyModule.Gone", + "DROP CONSTANT %s MyModule.Gone", + "DROP MICROFLOW %s MyModule.Gone", + "DROP NANOFLOW %s MyModule.Gone", + "DROP RULE %s MyModule.Gone", + "DROP PAGE %s MyModule.Gone", + "DROP LAYOUT %s MyModule.Gone", + "DROP SNIPPET %s MyModule.Gone", + "DROP MENU %s MyModule.Gone", + "DROP MODULE %s Gone", + "DROP QUEUE %s MyModule.Gone", + "DROP SCHEDULED EVENT %s MyModule.Gone", + "DROP REGULAR EXPRESSION %s MyModule.Gone", + "DROP JAVA ACTION %s MyModule.Gone", + "DROP JAVASCRIPT ACTION %s MyModule.Gone", + "DROP ODATA CLIENT %s MyModule.Gone", + "DROP ODATA SERVICE %s MyModule.Gone", + "DROP BUSINESS EVENT SERVICE %s MyModule.Gone", + "DROP WORKFLOW %s MyModule.Gone", + "DROP IMAGE COLLECTION %s MyModule.Gone", + "DROP JSON STRUCTURE %s MyModule.Gone", + "DROP MESSAGE DEFINITION COLLECTION %s MyModule.Gone", + "DROP IMPORT MAPPING %s MyModule.Gone", + "DROP EXPORT MAPPING %s MyModule.Gone", + "DROP REST CLIENT %s MyModule.Gone", + "DROP PUBLISHED REST SERVICE %s MyModule.Gone", + "DROP DATA TRANSFORMER %s MyModule.Gone", + "DROP MODEL %s MyModule.Gone", + "DROP CONSUMED MCP SERVICE %s MyModule.Gone", + "DROP KNOWLEDGE BASE %s MyModule.Gone", + "DROP AGENT %s MyModule.Gone", + "DROP CONFIGURATION %s 'Gone'", + "DROP FOLDER %s 'Gone' IN MyModule", +} + +func dropIfExistsCtx(t *testing.T) (*ExecContext, *strings.Builder) { + mod := mkModule("MyModule") + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil }, + GetDomainModelFunc: func(model.ID) (*domainmodel.DomainModel, error) { + return &domainmodel.DomainModel{ContainerID: mod.ID}, nil + }, + ListNanoflowsFunc: func() ([]*microflows.Nanoflow, error) { return nil, nil }, + ListRulesFunc: func() ([]*microflows.Rule, error) { return nil, nil }, + GetProjectSettingsFunc: func() (*model.ProjectSettings, error) { + return &model.ProjectSettings{Configuration: &model.ConfigurationSettings{}}, nil + }, + // The by-name lookups return an error for a missing document in the + // real backend (mdl/backend/modelsdk); mirror it, since a nil, nil + // from the mock is a shape production never produces. + GetImportMappingByQualifiedNameFunc: func(m, n string) (*model.ImportMapping, error) { + return nil, fmt.Errorf("import mapping not found: %s.%s", m, n) + }, + GetExportMappingByQualifiedNameFunc: func(m, n string) (*model.ExportMapping, error) { + return nil, fmt.Errorf("export mapping not found: %s.%s", m, n) + }, + GetJsonStructureByQualifiedNameFunc: func(m, n string) (*types.JsonStructure, error) { + return nil, fmt.Errorf("json structure not found: %s.%s", m, n) + }, + GetMenuDocumentByQualifiedNameFunc: func(m, n string) (*types.MenuDocument, error) { + return nil, fmt.Errorf("menu not found: %s.%s", m, n) + }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(mkHierarchy(mod))) + var out strings.Builder + ctx.Output = &out + return ctx, &out +} + +func runDropScript(t *testing.T, ctx *ExecContext, src string) error { + t.Helper() + prog, errs := visitor.Build(src + ";") + if len(errs) > 0 { + t.Fatalf("parse %q: %v", src, errs) + } + if len(prog.Statements) != 1 { + t.Fatalf("parse %q: got %d statements, want 1", src, len(prog.Statements)) + } + stmt := prog.Statements[0] + if _, ok := stmt.(ast.IfExistsDrop); !ok { + t.Fatalf("%q built %T, which does not carry the IF EXISTS guard", src, stmt) + } + return NewRegistry().Dispatch(ctx, stmt) +} + +// Issue #531: "DROP PAGE IF EXISTS FieldService.Stub; — line 1:13 extraneous +// input 'EXISTS' expecting the start of a statement". No document-level DROP +// accepted IF EXISTS, so a script that dropped anything could not be re-run. +func TestDropIfExists_MissingTargetIsSkipped(t *testing.T) { + for _, form := range dropIfExistsTargets { + src := strings.Replace(form, "%s", "IF EXISTS", 1) + t.Run(src, func(t *testing.T) { + ctx, out := dropIfExistsCtx(t) + if err := runDropScript(t, ctx, src); err != nil { + t.Fatalf("IF EXISTS on a missing target errored: %v", err) + } + if !strings.Contains(out.String(), "skipped") { + t.Errorf("a skipped drop should say so; output: %q", out.String()) + } + }) + } +} + +// CONTROL: the bare form must still fail on a missing target, and with a +// NotFoundError — that type is what the guard keys on, so a handler that +// reports "not found" any other way would pass the test above only if the +// guard swallowed every error. +func TestDropIfExists_BareFormStillReportsNotFound(t *testing.T) { + for _, form := range dropIfExistsTargets { + src := strings.Replace(form, "%s ", "", 1) + t.Run(src, func(t *testing.T) { + ctx, _ := dropIfExistsCtx(t) + err := runDropScript(t, ctx, src) + if err == nil { + t.Fatal("dropping a missing target succeeded without IF EXISTS") + } + var nf *mdlerrors.NotFoundError + if !errors.As(err, &nf) { + t.Errorf("missing target reported as %T (%v), want *NotFoundError", err, err) + } + }) + } +} + +// CONTROL: IF EXISTS must not swallow errors other than "not found". A +// disconnected project is the simplest one every handler checks first. +func TestDropIfExists_DoesNotSwallowOtherErrors(t *testing.T) { + ctx, _ := dropIfExistsCtx(t) + ctx.Backend = &mock.MockBackend{IsConnectedFunc: func() bool { return false }} + err := runDropScript(t, ctx, "DROP PAGE IF EXISTS MyModule.Gone") + if err == nil { + t.Fatal("IF EXISTS hid a not-connected error") + } +} + +// `check --references` resolves a drop's module and refused one that is absent. +// Under IF EXISTS an absent module means an absent target — the skip exec +// takes — so check must not be the stricter gate. +func TestDropIfExists_ReferenceCheckAcceptsMissingModule(t *testing.T) { + for _, src := range []string{ + "DROP ENTITY IF EXISTS NoSuchModule.Gone", + "DROP MODULE IF EXISTS NoSuchModule", + "DROP IMAGE COLLECTION IF EXISTS NoSuchModule.Gone", + } { + t.Run(src, func(t *testing.T) { + ctx, _ := dropIfExistsCtx(t) + prog, errs := visitor.Build(src + ";") + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + stmt := prog.Statements[0] + if err := validateWithContext(ctx, stmt, newScriptContext()); err != nil { + t.Errorf("check refused a guarded drop: %v", err) + } + // CONTROL: the unguarded form is still refused. + stmt.(ast.IfExistsDrop).SetDropIfExists(false) + if err := validateWithContext(ctx, stmt, newScriptContext()); err == nil { + t.Error("check accepted an unguarded drop of a missing module") + } + }) + } +} diff --git a/mdl/executor/helpers.go b/mdl/executor/helpers.go index fb1d46909..3265cfbd3 100644 --- a/mdl/executor/helpers.go +++ b/mdl/executor/helpers.go @@ -6,6 +6,7 @@ package executor import ( "fmt" + "sort" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -261,6 +262,15 @@ func validateWidgetReferences(ctx *ExecContext, widgets []*ast.WidgetV3, sc *scr } } + if len(refs.menus) > 0 { + known := buildMenuDocumentQualifiedNames(ctx) + for _, ref := range refs.menus { + if known != nil && !known[ref] && !sc.menus[ref] { + errors = append(errors, menuNotFoundMessage(ref, known)) + } + } + } + if len(refs.images) > 0 { // No same-script exemption: MDL cannot create an image collection entry, // so an image reference can only ever resolve against the project. @@ -279,6 +289,7 @@ type widgetRefCollector struct { snippets []string entities []string images []string + menus []string } // dedupe collapses repeated references within each category, preserving first @@ -293,6 +304,7 @@ func (c *widgetRefCollector) dedupe() { c.snippets = uniqueStrings(c.snippets) c.entities = uniqueStrings(c.entities) c.images = uniqueStrings(c.images) + c.menus = uniqueStrings(c.menus) } // uniqueStrings returns s with duplicate values removed, preserving order. @@ -315,7 +327,7 @@ func uniqueStrings(s []string) []string { func (c *widgetRefCollector) empty() bool { return len(c.microflows) == 0 && len(c.nanoflows) == 0 && len(c.pages) == 0 && len(c.snippets) == 0 && len(c.entities) == 0 && - len(c.images) == 0 + len(c.images) == 0 && len(c.menus) == 0 } func (c *widgetRefCollector) collectFromWidgets(widgets []*ast.WidgetV3) { @@ -363,6 +375,15 @@ func (c *widgetRefCollector) collectFromWidget(w *ast.WidgetV3) { } } + // A menu widget's menu document (`Menu: Module.Menu`). Only on the three + // menu widgets: `Menu` means nothing on any other. + switch strings.ToLower(w.Type) { + case "navigationtree", "menubar", "simplemenubar": + if m := strings.TrimSpace(w.GetStringProp("Menu")); m != "" { + c.menus = append(c.menus, m) + } + } + // Recurse into children c.collectFromWidgets(w.Children) } @@ -396,6 +417,43 @@ func (c *widgetRefCollector) collectFromAction(action *ast.ActionV3) { // Qualified Name Builders (used by validation and autocomplete) // ---------------------------------------------------------------------------- +// buildMenuDocumentQualifiedNames returns the project's menu documents by +// qualified name, or nil when they could not be listed — then nothing is +// reported, rather than every reference being called missing. +func buildMenuDocumentQualifiedNames(ctx *ExecContext) map[string]bool { + h, err := getHierarchy(ctx) + if err != nil { + return nil + } + mds, err := ctx.Backend.ListMenuDocuments() + if err != nil { + return nil + } + result := make(map[string]bool, len(mds)) + for _, md := range mds { + result[h.GetQualifiedName(md.ContainerID, md.Name)] = true + } + return result +} + +// menuNotFoundMessage names the menu documents that do exist, since MDL has no +// statement that lists them. +func menuNotFoundMessage(ref string, known map[string]bool) string { + names := make([]string, 0, len(known)) + for qn := range known { + names = append(names, qn) + } + sort.Strings(names) + const max = 10 + if len(names) > max { + names = append(names[:max:max], "…") + } + if len(names) == 0 { + return fmt.Sprintf("menu document not found: %s — the project has none; create one with `create menu`", ref) + } + return fmt.Sprintf("menu document not found: %s — the project has: %s", ref, strings.Join(names, ", ")) +} + // buildMicroflowQualifiedNames returns a set of all microflow qualified names in the project. func buildMicroflowQualifiedNames(ctx *ExecContext) map[string]bool { result := make(map[string]bool) diff --git a/mdl/executor/oql_comments.go b/mdl/executor/oql_comments.go new file mode 100644 index 000000000..e676d960a --- /dev/null +++ b/mdl/executor/oql_comments.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "strings" + +// stripOQLComments blanks out the comments in an OQL query so the text-level +// checks below never read one as query text. +// +// Every static OQL check here — the select-list split, the alias rules, type +// inference, the association columns — works on the raw query string, which +// keeps the author's comments because the query is stored verbatim. A `--` +// comment in the select list was therefore split out as a column of its own, +// and each comma inside it made another (mendixlabs/mxcli#1175): +// +// select column 1 has no as alias: '-- the customer's running total' +// +// Each comment character is replaced by a space and newlines are kept, so the +// result is the same length and every byte offset still points at the same +// place in the original. String literals ('…', with ” as the escape) and +// quoted identifiers ("…") are skipped, since `--` inside either is data. +func stripOQLComments(oql string) string { + if !strings.Contains(oql, "--") && !strings.Contains(oql, "/*") { + return oql + } + b := []byte(oql) + blank := func(from, to int) { + for k := from; k < to; k++ { + if b[k] != '\n' && b[k] != '\r' { + b[k] = ' ' + } + } + } + for i := 0; i < len(b); i++ { + switch c := b[i]; { + case c == '\'' || c == '"': + // Skip to the closing quote. A doubled quote ('it''s') reads as two + // adjacent runs, which lands in the same place. + for i++; i < len(b) && b[i] != c; i++ { + } + case c == '-' && i+1 < len(b) && b[i+1] == '-': + end := i + for end < len(b) && b[end] != '\n' { + end++ + } + blank(i, end) + i = end + case c == '/' && i+1 < len(b) && b[i+1] == '*': + end := len(b) + if k := strings.Index(oql[i+2:], "*/"); k >= 0 { + end = i + 2 + k + 2 + } + blank(i, end) + i = end - 1 + } + } + return string(b) +} diff --git a/mdl/executor/oql_type_inference.go b/mdl/executor/oql_type_inference.go index c52555ed2..0bed0832f 100644 --- a/mdl/executor/oql_type_inference.go +++ b/mdl/executor/oql_type_inference.go @@ -28,6 +28,7 @@ type OQLColumnInfo struct { func inferOQLTypes(ctx *ExecContext, oqlQuery string, declaredAttrs []ast.ViewAttribute) ([]OQLColumnInfo, []string) { var warnings []string var columns []OQLColumnInfo + oqlQuery = stripOQLComments(oqlQuery) // Extract SELECT clause selectClause := extractSelectClause(oqlQuery) @@ -176,6 +177,7 @@ func unquoteOQLIdent(s string) string { // CASE expressions, literals, datepart, etc.). func ValidateOQLTypes(oql string, attrs []ast.ViewAttribute) []linter.Violation { var violations []linter.Violation + oql = stripOQLComments(oql) selectClause := extractSelectClause(oql) if selectClause == "" { @@ -1163,6 +1165,7 @@ func extractFunctionArg(expr string) string { // This function can be called without an Executor instance. func ValidateOQLSyntax(oql string) []linter.Violation { var violations []linter.Violation + oql = stripOQLComments(oql) // Check for association paths using '.' instead of '/' assocDotPattern := regexp.MustCompile(`\b([a-zA-Z_][a-zA-Z0-9_]*)\.([A-Z][a-zA-Z0-9_]*)\.([A-Z][a-zA-Z0-9_]*_[A-Z][a-zA-Z0-9_]*)\b`) diff --git a/mdl/executor/oql_view_associations.go b/mdl/executor/oql_view_associations.go index 5f5892f23..820525a2a 100644 --- a/mdl/executor/oql_view_associations.go +++ b/mdl/executor/oql_view_associations.go @@ -68,6 +68,7 @@ var oqlIDColumnRe = regexp.MustCompile(`(?i)^([A-Za-z_]\w*)\s*\.\s*id$`) // viewAssociationColumns returns the select columns of oql that declare an // association, in select order. func viewAssociationColumns(oql string) []viewAssociationColumn { + oql = stripOQLComments(oql) selectClause := extractSelectClause(oql) if selectClause == "" { return nil diff --git a/mdl/executor/registry.go b/mdl/executor/registry.go index 53a3895a1..93054e789 100644 --- a/mdl/executor/registry.go +++ b/mdl/executor/registry.go @@ -3,6 +3,7 @@ package executor import ( + "errors" "fmt" "reflect" @@ -88,7 +89,19 @@ func (r *Registry) Dispatch(ctx *ExecContext, stmt ast.Statement) error { if h == nil { return mdlerrors.NewUnsupported(fmt.Sprintf("unhandled statement type %T", stmt)) } - return h(ctx, stmt) + err := h(ctx, stmt) + // DROP … IF EXISTS: a missing target is a skip, not a failure (#531). The + // guard lives here rather than in the ~35 drop handlers because every one + // of them already reports a missing target as NotFoundError; any other + // error — not connected, a failed write — still propagates. + if g, ok := stmt.(ast.IfExistsDrop); ok && g.DropIfExists() && err != nil { + var nf *mdlerrors.NotFoundError + if errors.As(err, &nf) { + fmt.Fprintf(ctx.Output, "%s, skipped (if exists)\n", nf.Error()) + return nil + } + } + return err } // Validate checks that every known AST statement type has a registered diff --git a/mdl/executor/validate.go b/mdl/executor/validate.go index bac143cac..eb98fd31b 100644 --- a/mdl/executor/validate.go +++ b/mdl/executor/validate.go @@ -33,6 +33,7 @@ type scriptContext struct { pages map[string]bool // Pages created (Module.Page) snippets map[string]bool // Snippets created (Module.Snippet) layouts map[string]bool // Layouts created (Module.Layout) + menus map[string]bool // Menu documents created (Module.Menu) constants map[string]bool // Constants created (Module.Constant) workflows map[string]bool // Workflows created (Module.Workflow) @@ -85,6 +86,7 @@ func newScriptContext() *scriptContext { workflows: make(map[string]bool), snippets: make(map[string]bool), layouts: make(map[string]bool), + menus: make(map[string]bool), constants: make(map[string]bool), javaActions: make(map[string][]string), @@ -208,6 +210,10 @@ func (sc *scriptContext) collectSingle(stmt ast.Statement) { if s.Name.Module != "" { sc.layouts[s.Name.String()] = true } + case *ast.CreateMenuStmt: + if s.Name.Module != "" { + sc.menus[s.Name.String()] = true + } case *ast.CreateWorkflowStmt: if s.Name.Module != "" { sc.workflows[s.Name.String()] = true @@ -448,6 +454,12 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext return err } + // DROP … IF EXISTS names something that may legitimately be absent, module + // included; exec skips it, so check must not refuse it (#531). + if g, ok := stmt.(ast.IfExistsDrop); ok && g.DropIfExists() { + return nil + } + switch s := stmt.(type) { // Statements that reference modules case *ast.CreateEntityStmt: @@ -627,6 +639,16 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext return mdlerrors.NewValidationf("page '%s' has argument errors:\n - %s", s.Name.String(), strings.Join(argErrors, "\n - ")) } + case *ast.CreateLayoutStmt: + // A layout's widgets name things too — a menu widget's menu document + // above all, since layouts are where menu widgets live. Before + // ako/mxcli#573 no layout was reference-checked at all, so + // `simplemenubar b (Menu: M.Typo)` passed --references and mxbuild + // answered CE1613 "The selected menu 'M.Typo' no longer exists." + if refErrors := validateWidgetReferences(ctx, s.Widgets, sc); len(refErrors) > 0 { + return mdlerrors.NewValidationf("layout '%s' has reference errors:\n - %s", + s.Name.String(), strings.Join(refErrors, "\n - ")) + } case *ast.CreateSnippetStmtV3: if s.Name.Module != "" && !sc.modules[s.Name.Module] { if _, err := findModule(ctx, s.Name.Module); err != nil { @@ -698,6 +720,13 @@ func validateWithContext(ctx *ExecContext, stmt ast.Statement, sc *scriptContext return mdlerrors.NewNotFound("module", s.Name.Module) } } + // ADD/DROP ATTRIBUTE on a view entity is refused by exec; say so here + // too, or check passes a script that exec stops halfway (#1173). + if s.Operation == ast.AlterEntityAddAttribute || s.Operation == ast.AlterEntityDropAttribute { + if err := validateViewEntityAttributeSet(ctx, s, sc); err != nil { + return err + } + } // Validate enumeration references in ADD ATTRIBUTE if s.Operation == ast.AlterEntityAddAttribute && s.Attribute != nil { attr := s.Attribute @@ -1374,3 +1403,27 @@ func allPageWidgets(s *ast.CreatePageStmtV3) []*ast.WidgetV3 { } return out } + +// validateViewEntityAttributeSet reports an ADD/DROP ATTRIBUTE whose target is a +// view entity — one the script creates, or one already in the project. An entity +// the script (re)creates as anything else is judged by that statement instead. +func validateViewEntityAttributeSet(ctx *ExecContext, s *ast.AlterEntityStmt, sc *scriptContext) error { + qn := s.Name.String() + isView := sc.viewEntities[qn] + if !isView && !sc.entities[qn] && s.Name.Module != "" && ctx.Connected() { + if ent, err := findEntity(ctx, s.Name.Module, s.Name.Name); err == nil { + isView = isViewEntity(ent) + } + } + if !isView { + return nil + } + if s.Operation == ast.AlterEntityAddAttribute { + name := "" + if s.Attribute != nil { + name = s.Attribute.Name + } + return viewEntityAttributeSetRefusal(qn, "add", name) + } + return viewEntityAttributeSetRefusal(qn, "drop", s.AttributeName) +} diff --git a/mdl/executor/validate_menu_refs_test.go b/mdl/executor/validate_menu_refs_test.go new file mode 100644 index 000000000..4ff7d5aae --- /dev/null +++ b/mdl/executor/validate_menu_refs_test.go @@ -0,0 +1,101 @@ +// 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/mdl/visitor" +) + +// ako/mxcli#573 gave the menu widgets `Menu: Module.Menu`, a new qualified name +// to get wrong. Measured on a blank 11.14.0 project, before this check: +// +// simplemenubar bottomBar (menu: Bug573.No_Such_Menu) +// +// mxcli check --references -> Check passed! +// mx check -> [CE1613] "The selected menu 'Bug573.No_Such_Menu' +// no longer exists." at Simple menu bar 'bottomBar' +// +// Two gaps: nothing collected a menu reference, and no layout was +// reference-checked at all — while layouts are where menu widgets live. + +func menuDocumentCtx(t *testing.T) *ExecContext { + t.Helper() + mod := mkModule("Atlas_Core") + md := &types.MenuDocument{ID: nextID("md"), ContainerID: mod.ID, Name: "Phone_Menu"} + h := mkHierarchy(mod) + withContainer(h, md.ContainerID, mod.ID) + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListMenuDocumentsFunc: func() ([]*types.MenuDocument, error) { return []*types.MenuDocument{md}, nil }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + return ctx +} + +func layoutWith(t *testing.T, widget string) *ast.CreateLayoutStmt { + t.Helper() + src := "create layout App.Phone (layouttype: 'Phone') {\n" + + " scrollcontainer sc1 {\n" + + " region bottom { " + widget + " }\n" + + " region center { placeholder Main }\n" + + " }\n}" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("does not parse: %v", errs) + } + return prog.Statements[0].(*ast.CreateLayoutStmt) +} + +func TestLayoutRefs_UnknownMenuDocumentIsReported(t *testing.T) { + ctx := menuDocumentCtx(t) + err := validateWithContext(ctx, layoutWith(t, "simplemenubar b (Menu: Atlas_Core.Phone_Mneu)"), newScriptContext()) + if err == nil { + t.Fatal("a simple menu bar naming a menu document that does not exist passed --references") + } + for _, want := range []string{"menu document not found: Atlas_Core.Phone_Mneu", "Atlas_Core.Phone_Menu"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error lacks %q: %v", want, err) + } + } +} + +// CONTROL: the right name resolves, on each of the three menu widgets. +func TestLayoutRefs_KnownMenuDocumentResolves(t *testing.T) { + ctx := menuDocumentCtx(t) + for _, w := range []string{ + "simplemenubar b (Menu: Atlas_Core.Phone_Menu)", + "menubar b (Menu: Atlas_Core.Phone_Menu)", + "navigationtree b (Menu: Atlas_Core.Phone_Menu)", + } { + if err := validateWithContext(ctx, layoutWith(t, w), newScriptContext()); err != nil { + t.Errorf("%s: %v", w, err) + } + } +} + +// The ordinary shape is one script that creates the menu and the layout. +func TestLayoutRefs_MenuCreatedEarlierInTheScriptResolves(t *testing.T) { + ctx := menuDocumentCtx(t) + sc := newScriptContext() + sc.collectSingle(&ast.CreateMenuStmt{Name: ast.QualifiedName{Module: "App", Name: "Bottom_Menu"}}) + if err := validateWithContext(ctx, layoutWith(t, "simplemenubar b (Menu: App.Bottom_Menu)"), sc); err != nil { + t.Errorf("a menu created earlier in the same script must be exempt: %v", err) + } +} + +// `Menu` is only a menu reference on a menu widget. +func TestWidgetRefs_MenuOnlyCollectedFromMenuWidgets(t *testing.T) { + refs := &widgetRefCollector{} + refs.collectFromWidgets([]*ast.WidgetV3{{ + Name: "c", Type: "container", Properties: map[string]any{"Menu": "X.Y"}, + }}) + if len(refs.menus) != 0 { + t.Errorf("collected %v from a container", refs.menus) + } +} diff --git a/mdl/executor/validate_oql_comments_test.go b/mdl/executor/validate_oql_comments_test.go new file mode 100644 index 000000000..c00d425e1 --- /dev/null +++ b/mdl/executor/validate_oql_comments_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" +) + +// mendixlabs/mxcli#1175: a `--` comment inside the select list was split into +// the column list as if it were a column, so a documented view reported +// +// select column 1 has no as alias: '-- the customer's running total' +// +// and, since the comment's own comma split it again, a phantom second column. +// Neither the apostrophe nor the comma is the cause — any comment in the +// select list was a column — but together they are the reported text. +const oqlWithSelectComment = `SELECT + -- the customer's running total, summed here rather than on the page + SUM(s.Amount) as Total, + /* the number of sales, from the table */ + COUNT(s.Amount) as Cnt + FROM MyFirstModule.Sale as s` + +func TestOQLComment_IsNotASelectColumn(t *testing.T) { + for _, v := range ValidateOQLSyntax(oqlWithSelectComment) { + t.Errorf("unexpected %s: %s", v.RuleID, v.Message) + } +} + +func TestStripOQLComments(t *testing.T) { + tests := []struct{ name, in, want string }{ + {"line comment", "a -- x, y\nb", "a \nb"}, + {"block comment", "a /* x */ b", "a b"}, + {"dashes in a string literal", "'a--b' as X", "'a--b' as X"}, + {"doubled quote in a literal", "'it''s -- no' as X", "'it''s -- no' as X"}, + {"dashes in a quoted identifier", `s."a--b" as X`, `s."a--b" as X`}, + {"apostrophe in a comment", "-- it's\n'x'", " \n'x'"}, + {"unterminated block", "a /* x", "a "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripOQLComments(tt.in) + if got != tt.want { + t.Errorf("stripOQLComments(%q)\n got: %q\nwant: %q", tt.in, got, tt.want) + } + if len(got) != len(tt.in) { + t.Errorf("length changed: %d -> %d (offsets must be preserved)", len(tt.in), len(got)) + } + }) + } + if strings.Contains(stripOQLComments(oqlWithSelectComment), "customer") { + t.Error("comment text survived") + } +} diff --git a/mdl/executor/view_entity_oql_roundtrip_test.go b/mdl/executor/view_entity_oql_roundtrip_test.go new file mode 100644 index 000000000..00e887bfa --- /dev/null +++ b/mdl/executor/view_entity_oql_roundtrip_test.go @@ -0,0 +1,114 @@ +// 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/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" +) + +// parseViewEntityQuery parses one `create view entity` statement and returns +// the OQL text exec would store (CreateViewEntityStmt.Query.RawQuery is +// written verbatim to the ViewEntitySourceDocument and the entity). +func parseViewEntityQuery(t *testing.T, src string) string { + t.Helper() + prog, errs := visitor.Build(src) + if len(errs) > 0 { + t.Fatalf("parse failed: %v\n%s", errs, src) + } + for _, stmt := range prog.Statements { + if s, ok := stmt.(*ast.CreateViewEntityStmt); ok { + return s.Query.RawQuery + } + } + t.Fatalf("no create view entity statement in:\n%s", src) + return "" +} + +// ako/mxcli#653: describe → exec of a view entity added two spaces of +// indentation to lines 2…n of the stored OQL on every cycle, so exec reported +// `Modified view entity` forever. Describe indents every line by two; exec +// kept everything after the query's first token verbatim, including those two. +// One pass hides it — the drift only shows when you describe again — so this +// runs the cycle three times and requires the stored text to stay put. +func TestViewEntityOQL_DescribeExecRoundTripIsStable(t *testing.T) { + cases := map[string]string{ + "column on its own line": `create view entity Shop.Ind ( + Total: Integer +) as ( + select + sum(s.Amount) as Total + from Shop.Sale as s +);`, + "column on the select line": `create view entity Shop.Ind ( + Total: Integer +) as ( + select sum(s.Amount) as Total + from Shop.Sale as s +);`, + } + // Queries already in a project, as Studio Pro stores them: flat, tab-indented, + // with blank lines and comments. Describe → exec must hand each back + // byte-identical — these were never written through mxcli's source text. + storedCases := map[string]string{ + "studio pro flat": "SELECT s.Amount AS Total\nFROM Shop.Sale AS s", + "tabs and blank line": "select\n\tsum(s.Amount) as Total\n\n\t-- every sale\nfrom Shop.Sale as s", + "relative indent kept": "select\n sum(s.Amount) as Total\n from Shop.Sale as s\n where s.Amount > 0", + "comment between clauses": "select sum(s.Amount) as Total\n/* all sales */\nfrom Shop.Sale as s", + } + for name, src := range cases { + storedCases["script: "+name] = src + } + for name, src := range storedCases { + t.Run(name, func(t *testing.T) { + stored := src + if _, fromScript := strings.CutPrefix(name, "script: "); fromScript { + stored = parseViewEntityQuery(t, src) // the first exec + } + + mod := mkModule("Shop") + dmID := nextID("dm") + ind := &domainmodel.Entity{ + BaseElement: model.BaseElement{ID: nextID("ent")}, + ContainerID: dmID, + Name: "Ind", + Persistable: true, + Source: "DomainModels$OqlViewEntitySource", + OqlQuery: stored, + Attributes: []*domainmodel.Attribute{{Name: "Total"}}, + } + dm := &domainmodel.DomainModel{ + BaseElement: model.BaseElement{ID: dmID}, + ContainerID: mod.ID, + Entities: []*domainmodel.Entity{ind}, + } + h := mkHierarchy(mod) + withContainer(h, dm.ID, mod.ID) + 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 }, + } + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + + for cycle := 1; cycle <= 3; cycle++ { + described, err := describeEntityToString(ctx, ast.QualifiedName{Module: "Shop", Name: "Ind"}) + if err != nil { + t.Fatalf("cycle %d: describe: %v", cycle, err) + } + got := parseViewEntityQuery(t, described) + if got != stored { + t.Fatalf("cycle %d: describe → exec changed the stored OQL\nwant %q\n got %q\ndescribed:\n%s", + cycle, stored, got, described) + } + ind.OqlQuery = got + } + }) + } +} diff --git a/mdl/grammar/MDLLexer.g4 b/mdl/grammar/MDLLexer.g4 index fdf73f5a6..72531c848 100644 --- a/mdl/grammar/MDLLexer.g4 +++ b/mdl/grammar/MDLLexer.g4 @@ -284,6 +284,7 @@ PLACEHOLDER: P L A C E H O L D E R; SCROLLCONTAINER: S C R O L L C O N T A I N E R; SCROLLREGION: R E G I O N; MENUBAR: M E N U B A R; +SIMPLEMENUBAR: S I M P L E M E N U B A R; NAVIGATIONTREE: N A V I G A T I O N T R E E; SNIPPETCALL: S N I P P E T C A L L; diff --git a/mdl/grammar/MDLParser.g4 b/mdl/grammar/MDLParser.g4 index 69048e9a7..2d5836a5c 100644 --- a/mdl/grammar/MDLParser.g4 +++ b/mdl/grammar/MDLParser.g4 @@ -468,44 +468,44 @@ createMenuStatement ; dropStatement - : DROP ENTITY qualifiedName - | DROP ASSOCIATION qualifiedName - | DROP ENUMERATION qualifiedName - | DROP CONSTANT qualifiedName - | DROP MICROFLOW qualifiedName - | DROP NANOFLOW qualifiedName - | DROP RULE qualifiedName - | DROP PAGE qualifiedName - | DROP LAYOUT qualifiedName - | DROP SNIPPET qualifiedName - | DROP MENU_KW qualifiedName - | DROP MODULE qualifiedName - | DROP QUEUE qualifiedName - | DROP SCHEDULED EVENT qualifiedName - | DROP REGULAR EXPRESSION qualifiedName - | DROP JAVA ACTION qualifiedName - | DROP JAVASCRIPT ACTION qualifiedName + : DROP ENTITY ifExists? qualifiedName + | DROP ASSOCIATION ifExists? qualifiedName + | DROP ENUMERATION ifExists? qualifiedName + | DROP CONSTANT ifExists? qualifiedName + | DROP MICROFLOW ifExists? qualifiedName + | DROP NANOFLOW ifExists? qualifiedName + | DROP RULE ifExists? qualifiedName + | DROP PAGE ifExists? qualifiedName + | DROP LAYOUT ifExists? qualifiedName + | DROP SNIPPET ifExists? qualifiedName + | DROP MENU_KW ifExists? qualifiedName + | DROP MODULE ifExists? qualifiedName + | DROP QUEUE ifExists? qualifiedName + | DROP SCHEDULED EVENT ifExists? qualifiedName + | DROP REGULAR EXPRESSION ifExists? qualifiedName + | DROP JAVA ACTION ifExists? qualifiedName + | DROP JAVASCRIPT ACTION ifExists? qualifiedName | DROP INDEX qualifiedName ON qualifiedName - | DROP ODATA CLIENT qualifiedName - | DROP ODATA SERVICE qualifiedName - | DROP BUSINESS EVENT SERVICE qualifiedName - | DROP WORKFLOW qualifiedName - | DROP IMAGE COLLECTION qualifiedName + | DROP ODATA CLIENT ifExists? qualifiedName + | DROP ODATA SERVICE ifExists? qualifiedName + | DROP BUSINESS EVENT SERVICE ifExists? qualifiedName + | DROP WORKFLOW ifExists? qualifiedName + | DROP IMAGE COLLECTION ifExists? qualifiedName | DROP ANNOTATION STRING_LITERAL IN identifierOrKeyword | DROP ANNOTATION AT_KW LPAREN NUMBER_LITERAL COMMA NUMBER_LITERAL RPAREN IN identifierOrKeyword - | DROP JSON STRUCTURE qualifiedName - | DROP MESSAGE DEFINITION COLLECTION qualifiedName - | DROP IMPORT MAPPING qualifiedName - | DROP EXPORT MAPPING qualifiedName - | DROP REST CLIENT qualifiedName - | DROP PUBLISHED REST SERVICE qualifiedName - | DROP DATA TRANSFORMER qualifiedName - | DROP MODEL qualifiedName // DROP MODEL Module.Name (agent-editor) - | DROP CONSUMED MCP SERVICE qualifiedName // DROP CONSUMED MCP SERVICE Module.Name - | DROP KNOWLEDGE BASE qualifiedName // DROP KNOWLEDGE BASE Module.Name - | DROP AGENT qualifiedName // DROP AGENT Module.Name - | DROP CONFIGURATION STRING_LITERAL - | DROP FOLDER STRING_LITERAL IN (qualifiedName | IDENTIFIER) + | DROP JSON STRUCTURE ifExists? qualifiedName + | DROP MESSAGE DEFINITION COLLECTION ifExists? qualifiedName + | DROP IMPORT MAPPING ifExists? qualifiedName + | DROP EXPORT MAPPING ifExists? qualifiedName + | DROP REST CLIENT ifExists? qualifiedName + | DROP PUBLISHED REST SERVICE ifExists? qualifiedName + | DROP DATA TRANSFORMER ifExists? qualifiedName + | DROP MODEL ifExists? qualifiedName // DROP MODEL Module.Name (agent-editor) + | DROP CONSUMED MCP SERVICE ifExists? qualifiedName // DROP CONSUMED MCP SERVICE Module.Name + | DROP KNOWLEDGE BASE ifExists? qualifiedName // DROP KNOWLEDGE BASE Module.Name + | DROP AGENT ifExists? qualifiedName // DROP AGENT Module.Name + | DROP CONFIGURATION ifExists? STRING_LITERAL + | DROP FOLDER ifExists? STRING_LITERAL IN (qualifiedName | IDENTIFIER) ; renameStatement diff --git a/mdl/grammar/domains/MDLCatalog.g4 b/mdl/grammar/domains/MDLCatalog.g4 index 90667202f..d5e1a0bf4 100644 --- a/mdl/grammar/domains/MDLCatalog.g4 +++ b/mdl/grammar/domains/MDLCatalog.g4 @@ -308,21 +308,36 @@ fromClause ; tableReference - : qualifiedName (AS? IDENTIFIER)? - | LPAREN oqlQuery RPAREN (AS? IDENTIFIER)? + : qualifiedName oqlSourceAlias? + | LPAREN oqlQuery RPAREN oqlSourceAlias? ; joinClause : joinType? JOIN tableReference (ON expression)? - | joinType? JOIN associationPath (AS? IDENTIFIER)? + | joinType? JOIN associationPath oqlSourceAlias? + ; + +// A source alias (FROM / JOIN / derived table). +// +// MDL's keywords are not OQL's, so a name like ROLE, STATUS or VALUE is a legal +// alias in a Mendix model, and DESCRIBE prints it verbatim — which made the +// round trip fail to parse (mendixlabs/mxcli#1174). After an explicit AS only +// an alias can follow, so any keyword is accepted there. Without AS the alias +// stays IDENTIFIER-only: `from M.Sale s left join …` must keep LEFT as the join. +oqlSourceAlias + : AS (IDENTIFIER | keyword) + | IDENTIFIER ; // OQL association path formats: // - Association/Entity (e.g., Shop.BillingAddress_Customer/Shop.Customer) // - alias/Association/Entity (e.g., c/Shop.DeliveryAddress_Customer/Shop.Address) +// +// The leading alias takes a keyword for the same reason as oqlSourceAlias: it +// names a source that may itself have been declared `as Role`. associationPath - : IDENTIFIER SLASH qualifiedName SLASH qualifiedName // alias/Association/Entity - | qualifiedName SLASH qualifiedName // Association/Entity + : (IDENTIFIER | keyword) SLASH qualifiedName SLASH qualifiedName // alias/Association/Entity + | qualifiedName SLASH qualifiedName // Association/Entity ; joinType diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index f7a2f59a7..d2d6190e0 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -421,6 +421,7 @@ widgetTypeV3 | SCROLLREGION | NAVIGATIONTREE | MENUBAR + | SIMPLEMENUBAR // Atlas phone bottom bar: renders a menu document (#573) // Object-list container keywords for pluggable widgets (Phase 1 — #538). // Each is the singular form of a Type:"object"+IsList:true widget property // (e.g. Accordion groups → GROUP). Routed at executor time via the parent diff --git a/mdl/grammar/domains/MDLSettings.g4 b/mdl/grammar/domains/MDLSettings.g4 index cfb31a232..3184f820d 100644 --- a/mdl/grammar/domains/MDLSettings.g4 +++ b/mdl/grammar/domains/MDLSettings.g4 @@ -677,7 +677,7 @@ keyword // Layout structure. SCROLLREGION is spelled `region`, an ordinary enough // word that leaving it out of this rule would make `region` unusable as an // attribute or widget name anywhere in MDL. - | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR + | SCROLLCONTAINER | SCROLLREGION | NAVIGATIONTREE | MENUBAR | SIMPLEMENUBAR // Object-list container keywords for pluggable widgets (#538) | GROUP | CUSTOMITEM | MARKER | DYNAMICMARKER | SERIES | LINE | SCALECOLOR | CUSTOMBUTTON | ALLOWEDFILEFORMAT diff --git a/mdl/visitor/oql_keyword_alias_test.go b/mdl/visitor/oql_keyword_alias_test.go new file mode 100644 index 000000000..f1f9b2503 --- /dev/null +++ b/mdl/visitor/oql_keyword_alias_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// mendixlabs/mxcli#1174: an OQL source alias that happens to be an MDL keyword +// did not parse, so DESCRIBE output of a view that Studio Pro accepts could not +// be fed back to exec: +// +// line 7:62 mismatched input 'ROLE' expecting IDENTIFIER +// line 9:0 mismatched input ')' expecting {SELECT, HAVING} +// +// MDL's keywords are not OQL's, so the alias is legal in the model; after an +// explicit AS nothing but an alias can follow, so any keyword is accepted there. + +func parseOQLView(t *testing.T, oql string) *ast.OQLParsed { + t.Helper() + src := "create view entity MyFirstModule.SaleStats (\n Total: Integer\n) as (\n" + oql + "\n);" + prog, errs := Build(src) + if len(errs) > 0 { + t.Fatalf("parse: %v", errs) + } + s, ok := prog.Statements[0].(*ast.CreateViewEntityStmt) + if !ok { + t.Fatalf("not a CREATE VIEW ENTITY: %T", prog.Statements[0]) + } + if s.Query.Parsed == nil { + t.Fatal("no structured parse") + } + return s.Query.Parsed +} + +func TestOQLKeywordAlias_JoinAsRole(t *testing.T) { + // The reported query, verbatim. + p := parseOQLView(t, ` SELECT + SUM(s.Amount) as Total + FROM MyFirstModule.Sale as s + LEFT JOIN s/MyFirstModule.Sale_UserRole/System.UserRole AS ROLE + GROUP BY ROLE.Name`) + if len(p.Tables) != 2 || p.Tables[1].Alias != "ROLE" { + t.Fatalf("tables = %+v, want the join aliased ROLE", p.Tables) + } + if p.GroupBy != "ROLE.Name" { + t.Errorf("group by = %q", p.GroupBy) + } +} + +func TestOQLKeywordAlias_FromSubqueryAndPathStart(t *testing.T) { + // A keyword alias on the FROM table, on a derived table, and as the start + // of a later association path — every place a source alias is written. + p := parseOQLView(t, ` select sum(role.Amount) as Total + from MyFirstModule.Sale as role + join role/MyFirstModule.Sale_Customer/MyFirstModule.Customer as Status + join (select c.ID as Id from MyFirstModule.Customer as c) as Value on Value.Id = Status.ID`) + want := []string{"role", "Status", "Value"} + if len(p.Tables) != len(want) { + t.Fatalf("tables = %+v", p.Tables) + } + for i, w := range want { + if p.Tables[i].Alias != w { + t.Errorf("table %d alias = %q, want %q", i, p.Tables[i].Alias, w) + } + } +} + +func TestOQLKeywordAlias_BareKeywordIsStillAClause(t *testing.T) { + // Control: without AS, a keyword after the source must stay the next clause. + // `from M.Sale s left join …` is a LEFT JOIN, not a table aliased LEFT. + p := parseOQLView(t, ` select sum(s.Amount) as Total + from MyFirstModule.Sale s + left join s/MyFirstModule.Sale_Customer/MyFirstModule.Customer c + where s.Amount > 0`) + if len(p.Tables) != 2 || p.Tables[0].Alias != "s" || p.Tables[1].Alias != "c" || + p.Tables[1].JoinType != "left join" { + t.Fatalf("tables = %+v", p.Tables) + } + if p.Where != "s.Amount > 0" { + t.Errorf("where = %q", p.Where) + } +} diff --git a/mdl/visitor/visitor_datasource_shorthand_test.go b/mdl/visitor/visitor_datasource_shorthand_test.go new file mode 100644 index 000000000..6d7e5df04 --- /dev/null +++ b/mdl/visitor/visitor_datasource_shorthand_test.go @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// `DataSource: Module.Entity` — the bare-entity shorthand — did not match +// dataSourceExprV3 (every alternative there starts with a keyword or a +// variable), so it fell through to the generic `keyword COLON propertyValueV3` +// branch and landed in Properties["DataSource"] as a plain string. Nothing +// downstream reads a string there: `check` passed, `exec` reported "Created +// page", DESCRIBE showed the grid with no source, and mxbuild reported CE0488 +// "No entity configured for the data source of this widgets container" +// (ako/mxcli#576). The shorthand now means what a reader expects: DATABASE. +func TestDataSourceBareEntityShorthand_BindsDatabase(t *testing.T) { + prog, errs := Build(`create page Mod.Shorthand (Title: 'Cars', Layout: Atlas_Core.Atlas_Default) { + datagrid dgShort (DataSource: Mod.Car, onClick: SHOW_PAGE Mod.Detail(Car: $currentObject)) { + column colName (Caption: 'Name', Attribute: Name) + } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + w := findWidget(page.Widgets, "dgShort") + if w == nil { + t.Fatal("datagrid dgShort not found") + } + ds, ok := w.Properties["DataSource"].(*ast.DataSourceV3) + if !ok || ds == nil { + t.Fatalf("DataSource = %#v (%T), want *ast.DataSourceV3 — the shorthand was dropped", + w.Properties["DataSource"], w.Properties["DataSource"]) + } + if ds.Type != "database" || ds.Reference != "Mod.Car" { + t.Errorf("DataSource = {Type: %q, Reference: %q}, want {database, Mod.Car}", ds.Type, ds.Reference) + } +} + +// A pluggable widget's generic keys are its own .mpk property keys, and two +// reuse the name: the Barcode Scanner's `datasource` binds an ATTRIBUTE +// (mendixlabs/mxcli#1161 writes it as `Module.Entity.Code`), and describe emits +// it back as the bare `datasource: Code`. Reading either as an entity broke the +// scanner — the first cut of this fix did exactly that. +func TestDataSourceBareEntityShorthand_PluggableKeyUntouched(t *testing.T) { + prog, errs := Build(`create page Mod.P (Title: 'T', Layout: Atlas_Core.Atlas_Default, Params: { $Scan: Mod.Scan }) { + dataview dv (DataSource: $Scan) { + barcodescanner bs1 (datasource: Mod.Scan.Code) + barcodescanner bs2 (datasource: Code) + } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + dv := findWidget(page.Widgets, "dv") + for name, want := range map[string]string{"bs1": "Mod.Scan.Code", "bs2": "Code"} { + var w *ast.WidgetV3 + for _, c := range dv.Children { + if c.Name == name { + w = c + } + } + if w == nil { + t.Fatalf("%s not found", name) + } + if got := w.Properties["datasource"]; got != want { + t.Errorf("%s: Properties[datasource] = %#v, want the attribute string %q", name, got, want) + } + if w.GetDataSource() != nil { + t.Errorf("%s: attribute key was read as a data source: %+v", name, w.GetDataSource()) + } + } +} + +// The explicit forms must keep their meaning — the shorthand must not capture +// a variable or a keyword-led source. +func TestDataSourceBareEntityShorthand_ExplicitFormsUnchanged(t *testing.T) { + prog, errs := Build(`create page Mod.P (Title: 'T', Layout: Atlas_Core.Atlas_Default, Params: { $Car: Mod.Car }) { + datagrid g1 (DataSource: DATABASE Mod.Car) { column c (Attribute: Name) } + dataview dv (DataSource: $Car) { textbox t (Attribute: Name) } + datagrid g2 (DataSource: MICROFLOW Mod.DS_Cars) { column c2 (Attribute: Name) } +};`) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + page := prog.Statements[0].(*ast.CreatePageStmtV3) + for name, want := range map[string]string{"g1": "database", "dv": "parameter", "g2": "microflow"} { + ds, ok := findWidget(page.Widgets, name).Properties["DataSource"].(*ast.DataSourceV3) + if !ok || ds.Type != want { + t.Errorf("%s: DataSource = %#v, want type %q", name, ds, want) + } + } +} diff --git a/mdl/visitor/visitor_entity.go b/mdl/visitor/visitor_entity.go index 6c152120e..a15f72aa0 100644 --- a/mdl/visitor/visitor_entity.go +++ b/mdl/visitor/visitor_entity.go @@ -189,7 +189,8 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { // OQL Query - use token stream to preserve whitespace, and walk parse tree for structured data if oqlCtx := ctx.OqlQuery(); oqlCtx != nil { - raw := extractOriginalText(oqlCtx) + firstIndent, firstIsIndent := leadingLineWhitespace(oqlCtx) + raw := dedentOQL(extractOriginalText(oqlCtx), firstIndent, firstIsIndent) stmt.Query = ast.OQLQuery{ RawQuery: raw, Parsed: buildOQLParsed(oqlCtx.(*parser.OqlQueryContext), raw), @@ -199,6 +200,70 @@ func (b *Builder) buildViewEntity(ctx *parser.CreateEntityStatementContext) { b.statements = append(b.statements, stmt) } +// leadingLineWhitespace returns the text between the start of the line holding +// ctx's first token and that token, and whether it is all whitespace. It is the +// indentation of the query's first line, which extractOriginalText drops. +func leadingLineWhitespace(ctx antlr.ParserRuleContext) (string, bool) { + start := ctx.GetStart() + if start == nil || start.GetInputStream() == nil || start.GetStart() < 0 { + return "", false + } + col := start.GetColumn() + if col <= 0 { + return "", true + } + prefix := start.GetInputStream().GetText(start.GetStart()-col, start.GetStart()-1) + return prefix, strings.TrimLeft(prefix, " \t") == "" +} + +// dedentOQL removes the indentation a view entity's query carries only because +// of where it sits in the script, so what is stored is the query itself. +// +// DESCRIBE indents every stored line by two spaces inside `as (…)`. The source +// text starts at the first token, so line 1 arrives without its indentation and +// lines 2…n with all of it; storing that verbatim added two spaces to lines 2…n +// on every describe → exec cycle (ako/mxcli#653). The common leading-whitespace +// prefix of the non-blank lines — line 1 counted at its position in the script +// when only whitespace precedes it — is stripped from lines 2…n. Nothing else +// is touched: relative indentation, comments and line breaks are the author's. +func dedentOQL(raw string, firstIndent string, firstIsIndent bool) string { + lines := strings.Split(raw, "\n") + if len(lines) < 2 { + return raw + } + common, have := "", false + if firstIsIndent { + common, have = firstIndent, true + } + for _, line := range lines[1:] { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" || trimmed == "\r" { + continue + } + indent := line[:len(line)-len(trimmed)] + if !have { + common, have = indent, true + continue + } + n := 0 + for n < len(common) && n < len(indent) && common[n] == indent[n] { + n++ + } + common = common[:n] + } + if common == "" { + return raw + } + for i := 1; i < len(lines); i++ { + if strings.HasPrefix(lines[i], common) { + lines[i] = lines[i][len(common):] + } else if strings.HasPrefix(common, lines[i]) { + lines[i] = "" // a blank line shorter than the indentation + } + } + return strings.Join(lines, "\n") +} + // buildOQLParsed walks the ANTLR OQL parse tree and returns a structured OQLParsed. func buildOQLParsed(ctx *parser.OqlQueryContext, rawQuery string) *ast.OQLParsed { if ctx == nil { @@ -341,13 +406,26 @@ func buildOQLFromTable(ctx *parser.TableReferenceContext) ast.OQLTableRef { } } - if id := ctx.IDENTIFIER(); id != nil { - ref.Alias = id.GetText() - } + ref.Alias = oqlSourceAliasText(ctx.OqlSourceAlias()) return ref } +// oqlSourceAliasText returns the alias an oqlSourceAlias names, without the AS. +func oqlSourceAliasText(ctx parser.IOqlSourceAliasContext) string { + if ctx == nil { + return "" + } + a := ctx.(*parser.OqlSourceAliasContext) + if id := a.IDENTIFIER(); id != nil { + return id.GetText() + } + if kw := a.Keyword(); kw != nil { + return kw.GetText() + } + return "" +} + // buildOQLJoinTable converts a JoinClauseContext into an OQLTableRef. func buildOQLJoinTable(ctx *parser.JoinClauseContext) ast.OQLTableRef { ref := ast.OQLTableRef{ @@ -369,9 +447,7 @@ func buildOQLJoinTable(ctx *parser.JoinClauseContext) ast.OQLTableRef { ref.Entity = sub.Tables[0].Entity } } - if id := tr.IDENTIFIER(); id != nil { - ref.Alias = id.GetText() - } + ref.Alias = oqlSourceAliasText(tr.OqlSourceAlias()) } else if assocPath := ctx.AssociationPath(); assocPath != nil { // Association path JOIN ap := assocPath.(*parser.AssociationPathContext) @@ -384,10 +460,8 @@ func buildOQLJoinTable(ctx *parser.JoinClauseContext) ast.OQLTableRef { ref.Entity = getQualifiedNameText(lastQN) } - // Alias is the IDENTIFIER on the JoinClause (not on AssociationPath) - if id := ctx.IDENTIFIER(); id != nil { - ref.Alias = id.GetText() - } + // Alias is on the JoinClause (not on AssociationPath) + ref.Alias = oqlSourceAliasText(ctx.OqlSourceAlias()) } // ON condition @@ -785,6 +859,19 @@ func (b *Builder) ExitAlterEntityAction(ctx *parser.AlterEntityActionContext) { // ExitDropStatement handles DROP ENTITY/ASSOCIATION/ENUMERATION/MODULE/MICROFLOW/PAGE/SNIPPET func (b *Builder) ExitDropStatement(ctx *parser.DropStatementContext) { + // IF EXISTS is one grammar rule shared by every document-level alternative, + // so it is applied here once, to whichever statement the chain below builds, + // rather than in each branch where the next doctype would forget it (#531). + built := len(b.statements) + defer func() { + if ctx.IfExists() == nil || len(b.statements) == built { + return + } + if g, ok := b.statements[len(b.statements)-1].(ast.IfExistsDrop); ok { + g.SetDropIfExists(true) + } + }() + // DROP CONFIGURATION uses STRING_LITERAL, not qualifiedName — handle first if ctx.CONFIGURATION() != nil { if sl := ctx.STRING_LITERAL(); sl != nil { diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index 685ee273c..faac425e8 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -907,12 +907,51 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } if valCtx := propCtx.PropertyValueV3(); valCtx != nil { + if ds := bareEntityDataSource(kw.GetText(), widget.Type, valCtx); ds != nil { + widget.Properties["DataSource"] = ds + return + } widget.Properties[kw.GetText()] = buildPropertyValueV3(valCtx) } return } } +// bareEntityWidgets are the widget keywords on which `DataSource:` can only mean +// a data source. A pluggable widget is deliberately absent: its generic keys are +// its own .mpk property keys, and two of them reuse the name — the Barcode +// Scanner's `datasource: Module.Entity.Attr` binds an ATTRIBUTE, and Image's +// `datasource` is its image-type enumeration. +var bareEntityWidgets = map[string]bool{ + "datagrid": true, "listview": true, "gallery": true, "dataview": true, +} + +// bareEntityDataSource resolves the `DataSource: Module.Entity` shorthand to +// `DataSource: DATABASE Module.Entity`, or returns nil when the property is not +// that shorthand. +// +// No dataSourceExprV3 alternative starts with a bare name, so the shorthand fell +// through to the generic `keyword: propertyValueV3` branch and was stored as a +// plain string that nothing reads — GetDataSource() only sees *DataSourceV3. The +// grid was written unbound: check passed, exec reported "Created page", and +// mxbuild answered CE0488 (ako/mxcli#576). It is resolved here rather than as a +// grammar alternative because only the widget type says what the key means. +// A data view gets the DATABASE source too, and MDL-WIDGET09 refuses it by name. +func bareEntityDataSource(key, widgetType string, valCtx parser.IPropertyValueV3Context) *ast.DataSourceV3 { + if !strings.EqualFold(key, "DataSource") || !bareEntityWidgets[widgetType] { + return nil + } + pv, ok := valCtx.(*parser.PropertyValueV3Context) + if !ok { + return nil + } + qn := pv.QualifiedName() + if qn == nil { + return nil + } + return &ast.DataSourceV3{Type: "database", Reference: getQualifiedNameText(qn)} +} + // buildDataSourceV3 builds a DataSource from the parse context. func buildDataSourceV3(ctx parser.IDataSourceExprV3Context) *ast.DataSourceV3 { if ctx == nil { diff --git a/mdl/visitor/visitor_view_entity_oql_indent_test.go b/mdl/visitor/visitor_view_entity_oql_indent_test.go new file mode 100644 index 000000000..fbd70bbd6 --- /dev/null +++ b/mdl/visitor/visitor_view_entity_oql_indent_test.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package visitor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" +) + +// ako/mxcli#653: the stored OQL of a view entity is the query, not its +// position in the script. Line 1 always lost its indentation (the source text +// starts at the first token) while lines 2…n kept all of theirs, so the +// indentation describe adds came back on every cycle. +func TestViewEntityOQL_StoresQueryWithoutScriptIndentation(t *testing.T) { + cases := []struct { + name, src, want string + }{ + { + name: "query on its own lines", + src: "create view entity Shop.V (Total: Integer) as (\n select\n sum(s.Amount) as Total\n from Shop.Sale as s\n);", + want: "select\n sum(s.Amount) as Total\nfrom Shop.Sale as s", + }, + { + name: "query starts on the as ( line", + src: "create view entity Shop.V (Total: Integer) as (select sum(s.Amount) as Total\n from Shop.Sale as s\n where s.Amount > 0);", + want: "select sum(s.Amount) as Total\nfrom Shop.Sale as s\nwhere s.Amount > 0", + }, + { + name: "continuation less indented than line 1 bounds the strip", + src: "create view entity Shop.V (Total: Integer) as (\n select sum(s.Amount) as Total\n from Shop.Sale as s\n);", + want: "select sum(s.Amount) as Total\nfrom Shop.Sale as s", + }, + { + name: "blank line and comment survive", + src: "create view entity Shop.V (Total: Integer) as (\n select sum(s.Amount) as Total\n\n -- every sale\n from Shop.Sale as s\n);", + want: "select sum(s.Amount) as Total\n\n-- every sale\nfrom Shop.Sale as s", + }, + { + name: "unindented query is untouched", + src: "create view entity Shop.V (Total: Integer) as (\nselect sum(s.Amount) as Total\n from Shop.Sale as s\n);", + want: "select sum(s.Amount) as Total\n from Shop.Sale as s", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + prog, errs := Build(tc.src) + if len(errs) > 0 { + t.Fatalf("parse failed: %v", errs) + } + stmt, ok := prog.Statements[0].(*ast.CreateViewEntityStmt) + if !ok { + t.Fatalf("got %T, want *ast.CreateViewEntityStmt", prog.Statements[0]) + } + if stmt.Query.RawQuery != tc.want { + t.Errorf("stored OQL\nwant %q\n got %q", tc.want, stmt.Query.RawQuery) + } + }) + } +} diff --git a/sdk/pages/pages_widgets_advanced.go b/sdk/pages/pages_widgets_advanced.go index eba3af1ac..9008587fd 100644 --- a/sdk/pages/pages_widgets_advanced.go +++ b/sdk/pages/pages_widgets_advanced.go @@ -16,8 +16,11 @@ type NavigationTree struct { BaseWidget // NavigationProfile names the profile the menu is drawn from. It is stored // inside a Forms$NavigationSource under MenuSource, not on the tree itself. - NavigationProfile string `json:"navigationProfile,omitempty"` - Items []*NavigationItem `json:"items,omitempty"` + NavigationProfile string `json:"navigationProfile,omitempty"` + // Menu names a Menus$MenuDocument instead, stored in a + // Forms$MenuDocumentSource. At most one of Menu and NavigationProfile is set. + Menu string `json:"menu,omitempty"` + Items []*NavigationItem `json:"items,omitempty"` } // NavigationItem represents an item in navigation. @@ -40,6 +43,8 @@ type NavigationItem struct { type MenuBar struct { BaseWidget NavigationProfile string `json:"navigationProfile,omitempty"` + // Menu names a menu document instead of a profile — see NavigationTree. + Menu string `json:"menu,omitempty"` // MenuSource is the older polymorphic form, kept because the type is // exported. Nothing reads or writes it. @@ -66,11 +71,22 @@ type CustomMenuSource struct { func (CustomMenuSource) isMenuSource() {} -// SimpleMenuBar represents a simple menu bar. +// SimpleMenuBar represents a simple menu bar — the widget Atlas's phone +// layouts put in their bottom region (ako/mxcli#573). +// +// Measured on Atlas_Core.Phone_BottomBar (11.14.0): Appearance, MenuSource, +// Name, Orientation, TabIndex, with MenuSource a Forms$MenuDocumentSource +// naming Atlas_Core.Phone_Menu. Like every menu widget it can take a +// navigation profile instead; at most one of Menu and NavigationProfile is set. type SimpleMenuBar struct { BaseWidget - Orientation MenuOrientation `json:"orientation"` - MenuSource MenuSource `json:"menuSource,omitempty"` + Orientation MenuOrientation `json:"orientation"` + Menu string `json:"menu,omitempty"` + NavigationProfile string `json:"navigationProfile,omitempty"` + + // MenuSource is the older polymorphic form, kept because the type is + // exported. Nothing reads or writes it. + MenuSource MenuSource `json:"menuSource,omitempty"` } // MenuOrientation represents menu orientation.