Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-25", "symptom": "mendixlabs/mxcli#1654: `check -p --references` passed `$r = call microflow AddOn.GetFirst(); declare $s String = $r/Secret;` where Secret is a Hidden attribute of a Usable entity in a Protected (add-on) module; mx check refused it with CE1406. The same gap left `change $r (Bogus = …)` on a call result unchecked (no CE1613)", "cause": "walkTypedFlow typed the variables of parameters, create, retrieve and loop iterators, not the OutputVariable of a CallMicroflowStmt. A member path on an untyped variable resolves no entity, and pathMembers(\"\") / checkMembers(\"\") return nothing", "file": "mdl/executor/validate_member_refs.go (walkTypedFlow, storedMicroflowReturnEntity), validate_protected_module_members.go (checkFlowMembers)", "insight": "Every binder walkTypedFlow does not type is a silent hole in every check that shares it. A call result can be typed from the stored callee's ReturnType (ObjectType/ListType carry EntityQualifiedName), but only when the script does not itself create or rewrite that microflow: the stored type may be the one being replaced, and a stale type turns a valid member into a false refusal that blocks exec. Silence is the safe side there.", "refs": ["mendixlabs/mxcli#1654", "mendixlabs/mxcli#1652", "mendixlabs/mxcli#1498"], "ce": ["CE1406", "CE1613"], "rules": []}
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **`check -p --references` knows the entity a `call microflow` returns** (mendixlabs/mxcli#1654) — `$r = call microflow AddOn.Request_GetFirst(); declare $s String = $r/Secret;` in an ordinary module passed `check` and `exec`, and mxbuild refused it with CE1406 because `Secret` is a Hidden attribute of a Protected module. The walk that types a flow's variables covered parameters, `create`, `retrieve` and loop iterators but not the output of `call microflow`, so every member check on a call result was silent — the CE1406 member check and the CE1613 check on `change $r (…)` alike. The output now holds the stored callee's return entity (an object, or a list of it, which also types a loop over it). A callee the same script creates or rewrites stays untyped, so its old stored type cannot cause a false refusal.
- **A pluggable widget's text-template parameter across an association builds, and describes so it re-executes** (mendixlabs/mxcli#1494) — `fieldset fs (legend: '{1}', legendParams: ({1} = M.Parent_Child/Label))` passed `check` and `exec` and failed the build with `[CE1365] "Attribute 'M.Child.Label' is not an attribute of entity 'M.Parent'."`: the parameter was written with its attribute but without the association hop, so mxbuild read the attribute against the widget's own entity. The same writer serves an object-list item's template (an Accordion group's `headerText`) and a Data grid 2 column's dynamic text, and all three failed; on Mendix 11.12.4 they now build with 0 errors. `describe page` printed such a parameter as the bare attribute (`{1} = Label`), which re-executed into CE1613 "The selected attribute 'M.Parent.Label' no longer exists"; it now prints `M.Parent_Child/Label`. The companion report, an expression parameter (`{1} = if 1 = 1 then 'A' else 'B'`) stored as an attribute name (mendixlabs/mxcli#1495), was fixed in v0.25.0 (#823) and is now pinned by a test.
- **`alter entity … add event handler` adds a second handler for the same event** (mendixlabs/mxcli#1540) — `add event handler on before commit call M.Second($currentObject)` failed with "event handler already exists for Before Commit" when the entity already had one, though Studio Pro allows several handlers for one event and `create or modify entity` with two `on before commit` clauses stores both (measured on 11.12.4: two Before Commit handlers are 0 errors, a void-returning one is CE7247, so the check does look at them). A handler is now identified by its moment, event and microflow: another microflow adds a second handler, the same microflow again is still a duplicate. The same key had made `add event handler if not exists … call M.New` skip — reporting "already exists" — whenever any handler for the event existed, and `drop event handler on before commit` remove whichever handler was stored first. `drop event handler` now takes `call M.Mf` to say which one; without it, a drop that matches several handlers is refused and names them.

Expand Down
47 changes: 45 additions & 2 deletions mdl/executor/validate_member_refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/mendixlabs/mxcli/mdl/ast"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

// `check --references` resolved the DOCUMENT and the ENTITY a statement names,
Expand Down Expand Up @@ -346,7 +347,8 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b
flowQN string, params []ast.MicroflowParam, body []ast.MicroflowStatement) []string {

var out []string
walkTypedFlow(ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) {
scriptFlows := func(qn string) bool { return sc != nil && sc.microflows[qn] }
walkTypedFlow(ctx, params, body, scriptFlows, func(st ast.MicroflowStatement, vars map[string]string) {
switch s := st.(type) {
case *ast.CreateObjectStmt:
out = append(out, checkMembers(ctx, sc, authored, flowQN,
Expand All @@ -373,8 +375,13 @@ func checkFlowMembers(ctx *ExecContext, sc *scriptContext, authored map[string]b
// (checkFlowMembers, the Protected-module member check): two copies of it would
// drift on which statements type a variable, and a check that loses a variable
// goes silent rather than wrong.
//
// scriptFlows names the microflows the script itself creates or rewrites: the
// output of a call to one of those is left untyped, because the stored return
// type may be the one the script is replacing, and a stale type would turn a
// valid member into a false refusal.
func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.MicroflowStatement,
visit func(st ast.MicroflowStatement, vars map[string]string)) {
scriptFlows func(qn string) bool, visit func(st ast.MicroflowStatement, vars map[string]string)) {

vars := map[string]string{}
for _, p := range params {
Expand Down Expand Up @@ -419,6 +426,15 @@ func walkTypedFlow(ctx *ExecContext, params []ast.MicroflowParam, body []ast.Mic
}
}
}
case *ast.CallMicroflowStmt:
// The output holds the callee's return entity (an object, or a
// list of it); without this a member read on a call result
// resolves no entity and every check on it is silent (#1654).
if s.OutputVariable != "" && !scriptFlows(s.MicroflowName.String()) {
if qn, ok := storedMicroflowReturnEntity(ctx, s.MicroflowName); ok {
vars[s.OutputVariable] = qn
}
}
case *ast.IfStmt:
walk(s.ThenBody)
walk(s.ElseBody)
Expand Down Expand Up @@ -654,3 +670,30 @@ func nearMembers(ctx *ExecContext, entityQN string) string {
}
return fmt.Sprintf(" — it has %s", strings.Join(names, ", "))
}

// storedMicroflowReturnEntity is the entity a stored microflow returns, as an
// object or a list of it. ok is false for any other return type and for a
// microflow that resolves nowhere.
func storedMicroflowReturnEntity(ctx *ExecContext, qn ast.QualifiedName) (string, bool) {
h, err := getHierarchy(ctx)
if err != nil {
return "", false
}
mfs, err := microflowsNamed(ctx, qn.Name)
if err != nil {
return "", false
}
for _, mf := range mfs {
if h.GetModuleName(h.FindModuleID(mf.ContainerID)) != qn.Module {
continue
}
switch t := mf.ReturnType.(type) {
case *microflows.ObjectType:
return t.EntityQualifiedName, t.EntityQualifiedName != ""
case *microflows.ListType:
return t.EntityQualifiedName, t.EntityQualifiedName != ""
}
return "", false
}
return "", false
}
45 changes: 45 additions & 0 deletions mdl/executor/validate_member_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/mendixlabs/mxcli/mdl/backend/mock"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/domainmodel"
"github.com/mendixlabs/mxcli/sdk/microflows"
)

// `CHANGE $Order ("IsArchived" = true)` — no such attribute — passed
Expand Down Expand Up @@ -373,6 +374,50 @@ func TestValidateMemberReferences_TypesALoopIterator(t *testing.T) {
}
}

// The output of `call microflow` holds the callee's return entity, so a member
// on it resolves like one on a parameter (mendixlabs/mxcli#1654). A list return
// types the list, so a LOOP over it types its iterator. A callee the script
// itself (re)creates is left untyped: its stored return type may be the one
// the script replaces.
func TestValidateMemberReferences_TypesACallMicroflowResult(t *testing.T) {
ctx := memberFixture(t)
shop, _ := ctx.Backend.GetModuleByName("Shop")
getOrder := mkMicroflow(shop.ID, "Order_Get")
getOrder.ReturnType = &microflows.ObjectType{EntityQualifiedName: "Shop.Order"}
listOrders := mkMicroflow(shop.ID, "Order_List")
listOrders.ReturnType = &microflows.ListType{EntityQualifiedName: "Shop.Order"}
ctx.Backend.(*mock.MockBackend).ListMicroflowsFunc = func() ([]*microflows.Microflow, error) {
return []*microflows.Microflow{getOrder, listOrders}, nil
}
call := func(out, name string) *ast.CallMicroflowStmt {
return &ast.CallMicroflowStmt{OutputVariable: out, MicroflowName: ast.QualifiedName{Module: "Shop", Name: name}}
}

msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"Bogus"`)), newScriptContext())
if len(msgs) != 1 || !strings.Contains(msgs[0], "Shop.Order") || !strings.Contains(msgs[0], "Bogus") {
t.Errorf("a CHANGE on a call result went unchecked: %v", msgs)
}

msgs = validateMemberReferences(ctx, flowProgram(nil, call("Orders", "Order_List"),
&ast.LoopStmt{LoopVariable: "O", ListVariable: "Orders",
Body: []ast.MicroflowStatement{changeStmt("O", `"Bogus"`)}}), newScriptContext())
if len(msgs) != 1 || !strings.Contains(msgs[0], "Bogus") {
t.Errorf("a CHANGE on the iterator over a list call result went unchecked: %v", msgs)
}

// CONTROL: a real member stays silent, so the entity is the callee's.
if msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"OrderNo"`)), newScriptContext()); len(msgs) != 0 {
t.Errorf("a real member of the call result was refused: %v", msgs)
}

// CONTROL: the script rewrites the callee, so its stored type is not trusted.
sc := newScriptContext()
sc.microflows["Shop.Order_Get"] = true
if msgs := validateMemberReferences(ctx, flowProgram(nil, call("O", "Order_Get"), changeStmt("O", `"Bogus"`)), sc); len(msgs) != 0 {
t.Errorf("typed a call result from the stored type of a microflow the script rewrites: %v", msgs)
}
}

// An association retrieve feeding a LOOP is the ordinary bulk-update shape, and
// leaving it untyped made the check silent on the commonest place a member is
// named. Both directions resolve, because a retrieve may traverse from either
Expand Down
3 changes: 2 additions & 1 deletion mdl/executor/validate_protected_module_members.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func (r *protectedRefResolver) checkFlowMembers(name ast.QualifiedName, params [
}
}

walkTypedFlow(r.ctx, params, body, func(st ast.MicroflowStatement, vars map[string]string) {
scriptFlows := func(qn string) bool { _, defined := r.scriptFlows[qn]; return defined }
walkTypedFlow(r.ctx, params, body, scriptFlows, func(st ast.MicroflowStatement, vars map[string]string) {
switch s := st.(type) {
case *ast.CreateObjectStmt:
who := "create " + s.EntityType.String()
Expand Down
14 changes: 14 additions & 0 deletions mdl/executor/validate_protected_module_refs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ func protectedModuleFixture(t *testing.T, settingsErr error) *ExecContext {
mf(addOn, "API_MF", "API"),
mf(open, "MF", "Hidden"),
}
// A Usable microflow returning the Usable entity Pub, for the members of a
// call result (#1654).
getPub := mf(addOn, "API_GetPub", "API")
getPub.ReturnType = &microflows.ObjectType{EntityQualifiedName: "MyAddOn.Pub"}
flows = append(flows, getPub)
// Members, for the CE1406 member cases: Pub and Pub2 are Usable entities
// with one Usable and one Hidden attribute each, joined by a Hidden and a
// Usable association. Open.E holds a Hidden attribute too — the control.
Expand Down Expand Up @@ -268,6 +273,15 @@ func TestValidateProtectedModuleRefs(t *testing.T) {
"create microflow MyAddOn.Inner ($p: MyAddOn.Pub) begin change $p (Inner = 'x'); retrieve $q from $p/MyAddOn.Pub_Pub2; end;", nil},
{"open module hidden attribute", "create microflow MyFirstModule.C ($e: Open.E) begin change $e (Inner = 'x'); end;", nil},
{"untyped variable is silent", "create microflow MyFirstModule.C () begin change $nobody (Inner = 'x'); end;", nil},
// --- a member of a call result (#1654) ---
{"hidden attribute on a call result",
"create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Inner; end;",
[]string{"CE1406", "the end event is not allowed to use attribute 'MyAddOn.Pub.Inner'"}},
{"hidden attribute on a call result, in a declare",
"create microflow MyFirstModule.G () begin $p = call microflow MyAddOn.API_GetPub(); declare $s String = $p/Inner; end;",
[]string{"CE1406", "declare $s is not allowed to use attribute 'MyAddOn.Pub.Inner'"}},
{"usable attribute on a call result",
"create microflow MyFirstModule.G () returns String begin $p = call microflow MyAddOn.API_GetPub(); return $p/Shown; end;", nil},

// --- controls: an ordinary module hides nothing ---
{"open module page", "create or modify navigation Responsive home page Open.P;", nil},
Expand Down
Loading