Skip to content

Commit 56d8606

Browse files
hjothamendixclaude
andcommitted
fix: preserve rule-based decision subtype across microflow roundtrips
An IF whose condition calls a Mendix rule (Module.RuleName(Param = arg)) was always serialized as Microflows$ExpressionSplitCondition, causing Mendix Studio Pro to raise CE0117 "Error(s) in expression" and silently demoting the decision subtype from Rule to Expression on every describe -> exec roundtrip. The flow builder now inspects the IF condition: when it is a qualified function call whose name resolves to a rule (checked via the new MicroflowBackend.IsRule), it emits Microflows$RuleSplitCondition with a nested RuleCall sub-document whose ParameterMappings carry the named arguments. Plain expressions and non-rule function calls still produce ExpressionSplitCondition. The MPR writer gained the corresponding BSON branch so the new split type reaches disk with the shape Studio Pro expects. To make the fix self-contained, this change also extends microflows.RuleSplitCondition with RuleQualifiedName and RuleCallParameterMapping with ParameterName — the two string fields the builder/writer/parser now round-trip through (model.ID-only pointers weren't enough to reconstruct the rule reference after exec). Regression tests cover: - Rule-call IF produces RuleSplitCondition with correct parameter names - Non-rule call IF still produces ExpressionSplitCondition - Flow builder without a backend (syntax-only check) falls back safely - Writer -> BSON -> parser roundtrip preserves RuleSplitCondition shape Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 94bded8 commit 56d8606

12 files changed

Lines changed: 514 additions & 18 deletions

mdl/backend/microflow.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,10 @@ type MicroflowBackend interface {
2626
UpdateNanoflow(nf *microflows.Nanoflow) error
2727
DeleteNanoflow(id model.ID) error
2828
MoveNanoflow(nf *microflows.Nanoflow) error
29+
30+
// IsRule reports whether the given qualified name refers to a rule
31+
// (Microflows$Rule) rather than a microflow. The flow builder uses this
32+
// to decide whether an IF condition that looks like a function call
33+
// (Module.Name(...)) should be serialized as a RuleSplitCondition.
34+
IsRule(qualifiedName string) (bool, error)
2935
}

mdl/backend/mock/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ type MockBackend struct {
8888
UpdateNanoflowFunc func(nf *microflows.Nanoflow) error
8989
DeleteNanoflowFunc func(id model.ID) error
9090
MoveNanoflowFunc func(nf *microflows.Nanoflow) error
91+
IsRuleFunc func(qualifiedName string) (bool, error)
9192

9293
// PageBackend
9394
ListPagesFunc func() ([]*pages.Page, error)

mdl/backend/mock/mock_microflow.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,10 @@ func (m *MockBackend) MoveNanoflow(nf *microflows.Nanoflow) error {
9797
}
9898
return nil
9999
}
100+
101+
func (m *MockBackend) IsRule(qualifiedName string) (bool, error) {
102+
if m.IsRuleFunc != nil {
103+
return m.IsRuleFunc(qualifiedName)
104+
}
105+
return false, nil
106+
}

mdl/backend/mpr/backend.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ func (b *MprBackend) DeleteMicroflow(id model.ID) error { return b.writer.Delete
221221
func (b *MprBackend) MoveMicroflow(mf *microflows.Microflow) error {
222222
return b.writer.MoveMicroflow(mf)
223223
}
224+
func (b *MprBackend) IsRule(qualifiedName string) (bool, error) {
225+
return b.reader.IsRule(qualifiedName)
226+
}
224227

225228
func (b *MprBackend) ListNanoflows() ([]*microflows.Nanoflow, error) {
226229
return b.reader.ListNanoflows()

mdl/executor/cmd_microflows_builder.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/mendixlabs/mxcli/mdl/ast"
1111
"github.com/mendixlabs/mxcli/mdl/backend"
12+
"github.com/mendixlabs/mxcli/mdl/types"
1213
"github.com/mendixlabs/mxcli/model"
1314
"github.com/mendixlabs/mxcli/sdk/microflows"
1415
)
@@ -170,3 +171,103 @@ func (fb *flowBuilder) resolvePathSegments(path []string) []string {
170171
}
171172
return resolved
172173
}
174+
175+
// buildSplitCondition constructs the right SplitCondition variant for an IF
176+
// statement. When the condition is a qualified call into a rule, it emits a
177+
// RuleSplitCondition (nested RuleCall with ParameterMappings). Everything else
178+
// falls back to ExpressionSplitCondition.
179+
//
180+
// Studio Pro enforces this distinction: a rule reference stored as an
181+
// expression fails validation with CE0117, which is the regression this
182+
// helper prevents on describe → exec roundtrips.
183+
func (fb *flowBuilder) buildSplitCondition(expr ast.Expression, fallbackExpression string) microflows.SplitCondition {
184+
if ruleCond := fb.tryBuildRuleSplitCondition(expr); ruleCond != nil {
185+
return ruleCond
186+
}
187+
return &microflows.ExpressionSplitCondition{
188+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
189+
Expression: fallbackExpression,
190+
}
191+
}
192+
193+
// tryBuildRuleSplitCondition returns a RuleSplitCondition when the expression
194+
// is a qualified function call that resolves to a rule via the backend.
195+
// Returns nil if the expression isn't a qualified call, if the backend is
196+
// unavailable, or if the name doesn't resolve to a rule.
197+
func (fb *flowBuilder) tryBuildRuleSplitCondition(expr ast.Expression) *microflows.RuleSplitCondition {
198+
if fb.backend == nil {
199+
return nil
200+
}
201+
call := unwrapParenCall(expr)
202+
if call == nil {
203+
return nil
204+
}
205+
// Only qualified names (Module.Name) can refer to rules; bare identifiers
206+
// are built-ins (length, contains, etc.).
207+
if !strings.Contains(call.Name, ".") {
208+
return nil
209+
}
210+
isRule, err := fb.backend.IsRule(call.Name)
211+
if err != nil || !isRule {
212+
return nil
213+
}
214+
215+
cond := &microflows.RuleSplitCondition{
216+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
217+
RuleQualifiedName: call.Name,
218+
}
219+
for _, arg := range call.Arguments {
220+
name, value := extractNamedArg(arg)
221+
if name == "" {
222+
// Positional arguments aren't representable in RuleCall — skip
223+
// rather than fabricate a parameter mapping that Studio Pro
224+
// would reject.
225+
continue
226+
}
227+
cond.ParameterMappings = append(cond.ParameterMappings, &microflows.RuleCallParameterMapping{
228+
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
229+
ParameterName: call.Name + "." + name,
230+
Argument: fb.exprToString(value),
231+
})
232+
}
233+
return cond
234+
}
235+
236+
// unwrapParenCall peels outer ParenExprs and returns the inner FunctionCallExpr
237+
// if present. Describer output wraps rule calls in parens when they sit inside
238+
// boolean expressions, so we must see through them.
239+
func unwrapParenCall(expr ast.Expression) *ast.FunctionCallExpr {
240+
for {
241+
switch e := expr.(type) {
242+
case *ast.FunctionCallExpr:
243+
return e
244+
case *ast.ParenExpr:
245+
expr = e.Inner
246+
default:
247+
return nil
248+
}
249+
}
250+
}
251+
252+
// extractNamedArg recognises `Name = value` BinaryExprs and returns the
253+
// parameter name + value. Anything else returns "", nil.
254+
//
255+
// The left side of a named-arg expression can surface as either an
256+
// IdentifierExpr (bare parameter name) or an AttributePathExpr with an empty
257+
// Variable — both forms come out of the visitor depending on surrounding
258+
// context, so handle them both.
259+
func extractNamedArg(expr ast.Expression) (string, ast.Expression) {
260+
bin, ok := expr.(*ast.BinaryExpr)
261+
if !ok || bin.Operator != "=" {
262+
return "", nil
263+
}
264+
switch left := bin.Left.(type) {
265+
case *ast.IdentifierExpr:
266+
return left.Name, bin.Right
267+
case *ast.AttributePathExpr:
268+
if left.Variable == "" && len(left.Path) == 1 {
269+
return left.Path[0], bin.Right
270+
}
271+
}
272+
return "", nil
273+
}

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,20 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
4343
splitX := fb.posX
4444
centerY := fb.posY // This is the center line for the happy path
4545

46-
// Create ExclusiveSplit with expression condition
47-
splitCondition := &microflows.ExpressionSplitCondition{
48-
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
49-
Expression: fb.exprToString(s.Condition),
50-
}
46+
// Decide whether the IF condition is a rule call or a plain expression.
47+
// A rule-based split must be serialized as Microflows$RuleSplitCondition;
48+
// emitting ExpressionSplitCondition for a rule call causes Studio Pro to
49+
// raise CE0117 "Error(s) in expression".
50+
caption := fb.exprToString(s.Condition)
51+
splitCondition := fb.buildSplitCondition(s.Condition, caption)
5152

5253
split := &microflows.ExclusiveSplit{
5354
BaseMicroflowObject: microflows.BaseMicroflowObject{
5455
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
5556
Position: model.Point{X: splitX, Y: centerY},
5657
Size: model.Size{Width: SplitWidth, Height: SplitHeight},
5758
},
58-
Caption: fb.exprToString(s.Condition),
59+
Caption: caption,
5960
SplitCondition: splitCondition,
6061
ErrorHandlingType: microflows.ErrorHandlingTypeRollback,
6162
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/ast"
9+
"github.com/mendixlabs/mxcli/mdl/backend/mock"
10+
"github.com/mendixlabs/mxcli/sdk/microflows"
11+
)
12+
13+
// TestIfWithRuleCall_EmitsRuleSplitCondition is the regression test for the
14+
// Rule vs Expression subtype preservation bug. Prior to the fix, an IF whose
15+
// condition was a call into a rule (e.g. ControlCenterCommons.IsNotEmptyString)
16+
// was serialized as ExpressionSplitCondition, causing Mendix Studio Pro to
17+
// raise CE0117 "Error(s) in expression" and demoting the decision's subtype
18+
// from Rule to Expression on every describe → exec roundtrip.
19+
func TestIfWithRuleCall_EmitsRuleSplitCondition(t *testing.T) {
20+
mb := &mock.MockBackend{
21+
IsRuleFunc: func(qualifiedName string) (bool, error) {
22+
return qualifiedName == "Module.IsEligible", nil
23+
},
24+
}
25+
26+
// if Module.IsEligible(Customer = $Customer) then return true else return false
27+
ifStmt := &ast.IfStmt{
28+
Condition: &ast.FunctionCallExpr{
29+
Name: "Module.IsEligible",
30+
Arguments: []ast.Expression{
31+
&ast.BinaryExpr{
32+
Left: &ast.IdentifierExpr{Name: "Customer"},
33+
Operator: "=",
34+
Right: &ast.VariableExpr{Name: "Customer"},
35+
},
36+
},
37+
},
38+
ThenBody: []ast.MicroflowStatement{
39+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
40+
},
41+
ElseBody: []ast.MicroflowStatement{
42+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: false, Kind: ast.LiteralBoolean}},
43+
},
44+
}
45+
46+
fb := &flowBuilder{
47+
posX: 100,
48+
posY: 100,
49+
spacing: HorizontalSpacing,
50+
backend: mb,
51+
varTypes: map[string]string{"Customer": "Module.Customer"},
52+
declaredVars: map[string]string{"Customer": "Module.Customer"},
53+
}
54+
fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
55+
56+
var split *microflows.ExclusiveSplit
57+
for _, obj := range fb.objects {
58+
if sp, ok := obj.(*microflows.ExclusiveSplit); ok {
59+
split = sp
60+
break
61+
}
62+
}
63+
if split == nil {
64+
t.Fatal("expected an ExclusiveSplit, found none")
65+
}
66+
67+
rule, ok := split.SplitCondition.(*microflows.RuleSplitCondition)
68+
if !ok {
69+
t.Fatalf("split condition: got %T, want *microflows.RuleSplitCondition", split.SplitCondition)
70+
}
71+
if rule.RuleQualifiedName != "Module.IsEligible" {
72+
t.Errorf("rule name: got %q, want %q", rule.RuleQualifiedName, "Module.IsEligible")
73+
}
74+
if len(rule.ParameterMappings) != 1 {
75+
t.Fatalf("parameter mappings: got %d, want 1", len(rule.ParameterMappings))
76+
}
77+
pm := rule.ParameterMappings[0]
78+
if pm.ParameterName != "Module.IsEligible.Customer" {
79+
t.Errorf("parameter name: got %q, want %q", pm.ParameterName, "Module.IsEligible.Customer")
80+
}
81+
if pm.Argument != "$Customer" {
82+
t.Errorf("argument: got %q, want %q", pm.Argument, "$Customer")
83+
}
84+
}
85+
86+
// TestIfWithNonRuleCall_EmitsExpressionSplitCondition confirms that a plain
87+
// expression-level function call (built-in or sub-microflow, not a rule) still
88+
// produces an ExpressionSplitCondition — the fix must not over-trigger.
89+
func TestIfWithNonRuleCall_EmitsExpressionSplitCondition(t *testing.T) {
90+
mb := &mock.MockBackend{
91+
IsRuleFunc: func(qualifiedName string) (bool, error) {
92+
return false, nil
93+
},
94+
}
95+
96+
ifStmt := &ast.IfStmt{
97+
Condition: &ast.FunctionCallExpr{
98+
Name: "empty",
99+
Arguments: []ast.Expression{&ast.VariableExpr{Name: "S"}},
100+
},
101+
ThenBody: []ast.MicroflowStatement{
102+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
103+
},
104+
}
105+
106+
fb := &flowBuilder{
107+
posX: 100,
108+
posY: 100,
109+
spacing: HorizontalSpacing,
110+
backend: mb,
111+
varTypes: map[string]string{"S": "String"},
112+
declaredVars: map[string]string{"S": "String"},
113+
}
114+
fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
115+
116+
var split *microflows.ExclusiveSplit
117+
for _, obj := range fb.objects {
118+
if sp, ok := obj.(*microflows.ExclusiveSplit); ok {
119+
split = sp
120+
break
121+
}
122+
}
123+
if split == nil {
124+
t.Fatal("expected an ExclusiveSplit, found none")
125+
}
126+
if _, ok := split.SplitCondition.(*microflows.ExpressionSplitCondition); !ok {
127+
t.Fatalf("split condition: got %T, want *microflows.ExpressionSplitCondition", split.SplitCondition)
128+
}
129+
}
130+
131+
// TestIfWithoutBackend_FallsBackToExpression confirms that when the flow
132+
// builder has no backend (e.g. disconnected check mode), it can't tell whether
133+
// a qualified call is a rule — it must default to ExpressionSplitCondition so
134+
// that syntax-only checks don't crash.
135+
func TestIfWithoutBackend_FallsBackToExpression(t *testing.T) {
136+
ifStmt := &ast.IfStmt{
137+
Condition: &ast.FunctionCallExpr{
138+
Name: "Module.IsEligible",
139+
Arguments: []ast.Expression{},
140+
},
141+
ThenBody: []ast.MicroflowStatement{
142+
&ast.ReturnStmt{Value: &ast.LiteralExpr{Value: true, Kind: ast.LiteralBoolean}},
143+
},
144+
}
145+
146+
fb := &flowBuilder{
147+
posX: 100,
148+
posY: 100,
149+
spacing: HorizontalSpacing,
150+
varTypes: map[string]string{},
151+
declaredVars: map[string]string{},
152+
}
153+
fb.buildFlowGraph([]ast.MicroflowStatement{ifStmt}, nil)
154+
155+
for _, obj := range fb.objects {
156+
if sp, ok := obj.(*microflows.ExclusiveSplit); ok {
157+
if _, ok := sp.SplitCondition.(*microflows.ExpressionSplitCondition); !ok {
158+
t.Fatalf("without backend, split condition: got %T, want *microflows.ExpressionSplitCondition", sp.SplitCondition)
159+
}
160+
return
161+
}
162+
}
163+
t.Fatal("expected an ExclusiveSplit, found none")
164+
}

sdk/microflows/microflows.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,19 +290,25 @@ type ExpressionSplitCondition struct {
290290
func (ExpressionSplitCondition) isSplitCondition() {}
291291

292292
// RuleSplitCondition represents a rule-based split condition.
293+
// In Mendix BSON the rule is referenced by qualified name under the
294+
// "RuleCall.Microflow" field (rules and microflows share a namespace).
293295
type RuleSplitCondition struct {
294296
model.BaseElement
295297
RuleID model.ID `json:"ruleId"`
298+
RuleQualifiedName string `json:"ruleQualifiedName,omitempty"`
296299
ParameterMappings []*RuleCallParameterMapping `json:"parameterMappings,omitempty"`
297300
}
298301

299302
func (RuleSplitCondition) isSplitCondition() {}
300303

301304
// RuleCallParameterMapping maps a parameter to a value.
305+
// ParameterName is the fully qualified parameter name (e.g. "Module.Rule.ParamName")
306+
// as stored in BSON; used when the describer renders the rule call.
302307
type RuleCallParameterMapping struct {
303308
model.BaseElement
304-
ParameterID model.ID `json:"parameterId"`
305-
Argument string `json:"argument"`
309+
ParameterID model.ID `json:"parameterId"`
310+
ParameterName string `json:"parameterName,omitempty"`
311+
Argument string `json:"argument"`
306312
}
307313

308314
// LoopSource is the source for a LoopedActivity. Either IterableList (FOR EACH) or WhileLoopCondition (WHILE).

0 commit comments

Comments
 (0)