Skip to content
Merged
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
45 changes: 45 additions & 0 deletions mdl-examples/bug-tests/263-nested-caption-preservation.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- ============================================================================
-- Bug #263: Preserve decision/loop captions across nested control flow
-- ============================================================================
--
-- Symptom (before fix):
-- `@caption` on an outer IF/LOOP/WHILE was being overwritten by the inner
-- IF/LOOP's caption because `pendingAnnotations` was shared mutable state
-- across recursive addStatement calls. Annotations attached to the outer
-- split ended up bound to the inner split, and the outer split inherited
-- whatever caption the inner statement carried.
--
-- After fix:
-- addIfStatement / addLoopStatement / addWhileStatement snapshot + clear
-- `pendingAnnotations` before recursing, then re-apply to their own activity
-- after it's created. The WHILE case also gained explicit handling in
-- mergeStatementAnnotations (previously fell through to `default: nil`).
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/263-nested-caption-preservation.mdl -p app.mpr
-- Open in Studio Pro — each split/loop displays its own caption, not
-- inherited from a nested statement.
-- ============================================================================

create module BugTest263;

create microflow BugTest263.MF_NestedCaptions (
$S: string
)
returns boolean as $ok
begin
declare $ok boolean = false;

@caption 'String not empty?'
if $S != empty then
@caption 'Right format?'
if isMatch($S, 'x') then
return true;
else
return false;
end if;
else
return false;
end if;
end;
/
6 changes: 6 additions & 0 deletions mdl/backend/microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,10 @@ type MicroflowBackend interface {
UpdateNanoflow(nf *microflows.Nanoflow) error
DeleteNanoflow(id model.ID) error
MoveNanoflow(nf *microflows.Nanoflow) error

// IsRule reports whether the given qualified name refers to a rule
// (Microflows$Rule) rather than a microflow. The flow builder uses this
// to decide whether an IF condition that looks like a function call
// (Module.Name(...)) should be serialized as a RuleSplitCondition.
IsRule(qualifiedName string) (bool, error)
}
1 change: 1 addition & 0 deletions mdl/backend/mock/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type MockBackend struct {
UpdateNanoflowFunc func(nf *microflows.Nanoflow) error
DeleteNanoflowFunc func(id model.ID) error
MoveNanoflowFunc func(nf *microflows.Nanoflow) error
IsRuleFunc func(qualifiedName string) (bool, error)

// PageBackend
ListPagesFunc func() ([]*pages.Page, error)
Expand Down
7 changes: 7 additions & 0 deletions mdl/backend/mock/mock_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,10 @@ func (m *MockBackend) MoveNanoflow(nf *microflows.Nanoflow) error {
}
return nil
}

func (m *MockBackend) IsRule(qualifiedName string) (bool, error) {
if m.IsRuleFunc != nil {
return m.IsRuleFunc(qualifiedName)
}
return false, nil
}
3 changes: 3 additions & 0 deletions mdl/backend/mpr/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ func (b *MprBackend) DeleteMicroflow(id model.ID) error { return b.writer.Delete
func (b *MprBackend) MoveMicroflow(mf *microflows.Microflow) error {
return b.writer.MoveMicroflow(mf)
}
func (b *MprBackend) IsRule(qualifiedName string) (bool, error) {
return b.reader.IsRule(qualifiedName)
}

func (b *MprBackend) ListNanoflows() ([]*microflows.Nanoflow, error) {
return b.reader.ListNanoflows()
Expand Down
101 changes: 101 additions & 0 deletions mdl/executor/cmd_microflows_builder.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/mdl/backend"
"github.com/mendixlabs/mxcli/mdl/types"
"github.com/mendixlabs/mxcli/model"
"github.com/mendixlabs/mxcli/sdk/microflows"
)
Expand Down Expand Up @@ -170,3 +171,103 @@ func (fb *flowBuilder) resolvePathSegments(path []string) []string {
}
return resolved
}

// buildSplitCondition constructs the right SplitCondition variant for an IF
// statement. When the condition is a qualified call into a rule, it emits a
// RuleSplitCondition (nested RuleCall with ParameterMappings). Everything else
// falls back to ExpressionSplitCondition.
//
// Studio Pro enforces this distinction: a rule reference stored as an
// expression fails validation with CE0117, which is the regression this
// helper prevents on describe → exec roundtrips.
func (fb *flowBuilder) buildSplitCondition(expr ast.Expression, fallbackExpression string) microflows.SplitCondition {
if ruleCond := fb.tryBuildRuleSplitCondition(expr); ruleCond != nil {
return ruleCond
}
return &microflows.ExpressionSplitCondition{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
Expression: fallbackExpression,
}
}

// tryBuildRuleSplitCondition returns a RuleSplitCondition when the expression
// is a qualified function call that resolves to a rule via the backend.
// Returns nil if the expression isn't a qualified call, if the backend is
// unavailable, or if the name doesn't resolve to a rule.
func (fb *flowBuilder) tryBuildRuleSplitCondition(expr ast.Expression) *microflows.RuleSplitCondition {
if fb.backend == nil {
return nil
}
call := unwrapParenCall(expr)
if call == nil {
return nil
}
// Only qualified names (Module.Name) can refer to rules; bare identifiers
// are built-ins (length, contains, etc.).
if !strings.Contains(call.Name, ".") {
return nil
}
isRule, err := fb.backend.IsRule(call.Name)
if err != nil || !isRule {
return nil
}

cond := &microflows.RuleSplitCondition{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
RuleQualifiedName: call.Name,
}
for _, arg := range call.Arguments {
name, value := extractNamedArg(arg)
if name == "" {
// Positional arguments aren't representable in RuleCall — skip
// rather than fabricate a parameter mapping that Studio Pro
// would reject.
continue
}
cond.ParameterMappings = append(cond.ParameterMappings, &microflows.RuleCallParameterMapping{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
ParameterName: call.Name + "." + name,
Argument: fb.exprToString(value),
})
}
return cond
}

// unwrapParenCall peels outer ParenExprs and returns the inner FunctionCallExpr
// if present. Describer output wraps rule calls in parens when they sit inside
// boolean expressions, so we must see through them.
func unwrapParenCall(expr ast.Expression) *ast.FunctionCallExpr {
for {
switch e := expr.(type) {
case *ast.FunctionCallExpr:
return e
case *ast.ParenExpr:
expr = e.Inner
default:
return nil
}
}
}

// extractNamedArg recognises `Name = value` BinaryExprs and returns the
// parameter name + value. Anything else returns "", nil.
//
// The left side of a named-arg expression can surface as either an
// IdentifierExpr (bare parameter name) or an AttributePathExpr with an empty
// Variable — both forms come out of the visitor depending on surrounding
// context, so handle them both.
func extractNamedArg(expr ast.Expression) (string, ast.Expression) {
bin, ok := expr.(*ast.BinaryExpr)
if !ok || bin.Operator != "=" {
return "", nil
}
switch left := bin.Left.(type) {
case *ast.IdentifierExpr:
return left.Name, bin.Right
case *ast.AttributePathExpr:
if left.Variable == "" && len(left.Path) == 1 {
return left.Path[0], bin.Right
}
}
return "", nil
}
22 changes: 20 additions & 2 deletions mdl/executor/cmd_microflows_builder_annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ func getStatementAnnotations(stmt ast.MicroflowStatement) *ast.ActivityAnnotatio
return s.Annotations
case *ast.LoopStmt:
return s.Annotations
case *ast.WhileStmt:
return s.Annotations
case *ast.LogStmt:
return s.Annotations
case *ast.CallMicroflowStmt:
Expand Down Expand Up @@ -116,8 +118,8 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
continue
}

// @caption, @color, and @excluded — only applicable to ActionActivity
if activity, ok := obj.(*microflows.ActionActivity); ok {
switch activity := obj.(type) {
case *microflows.ActionActivity:
if ann.Caption != "" {
activity.Caption = ann.Caption
activity.AutoGenerateCaption = false
Expand All @@ -128,6 +130,22 @@ func (fb *flowBuilder) applyAnnotations(activityID model.ID, ann *ast.ActivityAn
if ann.Excluded {
activity.Disabled = true
}
case *microflows.ExclusiveSplit:
// Splits carry a human-readable Caption (e.g. "Right format?")
// independent of the expression/rule being evaluated.
if ann.Caption != "" {
activity.Caption = ann.Caption
}
case *microflows.InheritanceSplit:
if ann.Caption != "" {
activity.Caption = ann.Caption
}
case *microflows.LoopedActivity:
// LOOP / WHILE activities can carry a caption just like
// splits and action activities.
if ann.Caption != "" {
activity.Caption = ann.Caption
}
}

break
Expand Down
Loading