Skip to content

Commit 907184b

Browse files
authored
Merge pull request #265 from hjotha/submit/microflow-split-subtype-preservation
fix: preserve rule-based decision subtype across microflow roundtrips
2 parents 25a4252 + 57f8739 commit 907184b

12 files changed

Lines changed: 514 additions & 21 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 & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,19 +290,24 @@ 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
295-
RuleID model.ID `json:"ruleId"`
297+
RuleQualifiedName string `json:"ruleQualifiedName,omitempty"`
296298
ParameterMappings []*RuleCallParameterMapping `json:"parameterMappings,omitempty"`
297299
}
298300

299301
func (RuleSplitCondition) isSplitCondition() {}
300302

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

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

0 commit comments

Comments
 (0)