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
1 change: 1 addition & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ authentication basic, session
| List declaration | `declare $list list of Module.Entity = empty;` | |
| Assignment | `set $Var = expression;` | Variable must be declared first |
| Create object | `$Var = create Module.Entity (attr = value);` | |
| Duplicate implicit output | `$Var`, `$Var_2`, `$Var_3` | Describe may alias same-position duplicate outputs for round-trip preservation |
| Change object | `change $entity (attr = value);` | |
| Commit | `commit $entity [with events] [refresh];` | |
| Delete | `delete $entity;` | |
Expand Down
62 changes: 62 additions & 0 deletions docs/11-proposals/PROPOSAL_microflow_variable_alias_collision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Microflow Variable Alias Collision

Status: Draft

## Summary

Document the round-trip-only aliasing rule used when `describe` encounters
multiple implicit output variables with the same name at the same microflow
position.

When the builder detects a duplicate implicit output at the same canvas point,
the later output is renamed with a numeric suffix:

```mdl
$Item = create SampleModule.Item ();
$Item = create SampleModule.Item (); -- becomes Item_2 internally
```

References emitted after the aliased activity are rewritten to the generated
name (`$Item_2`, `$Item_3`, and so on) so the generated Mendix model remains
valid.

## Motivation

Some legacy projects contain duplicated or ambiguous implicit output variables
that Studio Pro can keep in the model but MDL cannot represent as repeated
variables in the same scope without ambiguity. Failing the round-trip would
block describe/exec use on those projects. Silently reusing the first variable
would also be wrong because later changes, returns, or association paths would
target the wrong object.

The aliasing rule preserves a valid model while making the generated MDL
deterministic and reviewable.

## Semantics

- Aliasing is position-scoped. A duplicate implicit output only aliases when
the same variable name is produced at the same `@position(x, y)`.
- The first output keeps its original name.
- Later outputs are renamed to the first available suffix:
`Foo_2`, `Foo_3`, and so on.
- Subsequent variable references and attribute paths are rewritten to the active
alias.
- Moving to a different position resets the alias for that variable name.

This is primarily a describe/round-trip preservation rule. Authored MDL should
prefer explicit unique variable names.

## Tests And Examples

- Builder coverage verifies duplicate implicit outputs are emitted as
`SelectedItem` and `SelectedItem_2`, and that downstream references follow
the alias.
- Example script:
`mdl-examples/doctype-tests/variable_alias_collision.test.mdl`.

## Open Questions

- Should the builder fail with an explicit disambiguation error instead of
aliasing silently when authored MDL contains this pattern?
- Should `describe` emit a comment near generated aliases so users can
distinguish model-preservation aliases from names authored manually?
1 change: 1 addition & 0 deletions docs/11-proposals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ BSON schema Registry ◄──── multi-version Support
|----------|--------|---------|------------|
| [MDL Syntax Improvements v1](PROPOSAL_mdl_syntax_improvements.md) | Draft | Go-style assignment, C-style braces, fluent list APIs | — |
| [MDL Syntax Improvements v2](PROPOSAL_mdl_syntax_improvements_v2.md) | Proposed | Consolidated v2: unified variable declaration, C-style braces, fluent list ops | Syntax Improvements v1 |
| [Microflow Variable Alias Collision](PROPOSAL_microflow_variable_alias_collision.md) | Draft | Deterministic `Foo_2` aliases for duplicate implicit outputs at the same microflow position | — |
| [Page Syntax V2](PROPOSAL_page_syntax_v2.md) | Superseded | Page/widget syntax with `{}` blocks and `->` binding. Superseded by V3 (archived) | — |
| [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done | — |
| [Page Composition](proposal_page_composition.md) | Proposed | Fragment definitions and ALTER PAGE for partial page editing | Page Syntax V2, Page Styling |
Expand Down
52 changes: 52 additions & 0 deletions mdl-examples/bug-tests/variable-alias-collision.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- ============================================================================
-- Variable alias collision: duplicate implicit output at same position
-- ============================================================================
--
-- Symptom (before fix):
-- When a Studio Pro project contained two activities producing the same
-- implicit output variable at the same canvas position (e.g. two
-- `Create Object` actions both writing `$Item` at `@position(100, 200)`),
-- describing it produced MDL with two `$Item =` assignments and the
-- builder collapsed both into one shared variable. Downstream `change`
-- and `return` statements then operated on the wrong object, the
-- resulting BSON was non-equivalent to the original, and roundtrip
-- verification failed.
--
-- After fix:
-- The flow builder now tracks implicit output positions. When it sees
-- the same variable name produced again at the same `@position(x, y)`,
-- it assigns a deterministic suffix (`Foo_2`, `Foo_3`, …) and rewrites
-- subsequent variable references, attribute paths, change targets, and
-- retrieve start variables through the active alias. Roundtrip
-- describe→exec→describe is a fixpoint and the resulting model is valid.
--
-- Usage:
-- mxcli exec mdl-examples/bug-tests/variable-alias-collision.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe microflow SampleAliases.ACT_DuplicateOutputPosition"
-- The describe output must contain `$Item_2` for the second create and
-- for the downstream `change`/`return`. Studio Pro `mx check` must
-- report 0 errors.
-- ============================================================================

create module SampleAliases;

create entity SampleAliases.Item (
Name : string(100)
);
/

create microflow SampleAliases.ACT_DuplicateOutputPosition ()
returns SampleAliases.Item as $Item
begin
@position(100, 200)
$Item = create SampleAliases.Item (Name = 'first');

@position(100, 200)
$Item = create SampleAliases.Item (Name = 'second');

change $Item (
Name = $Item/Name + ' updated');

return $Item;
end;
/
17 changes: 17 additions & 0 deletions mdl-examples/doctype-tests/variable_alias_collision.test.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
create microflow SampleAliases.ACT_DuplicateOutputPosition ()
returns SampleAliases.Item as $Item
begin
@position(100, 200)
$Item = create SampleAliases.Item (
Name = 'first');

@position(100, 200)
$Item = create SampleAliases.Item (
Name = 'second');

change $Item (
Name = $Item/Name + ' updated');

return $Item;
end;
/
52 changes: 51 additions & 1 deletion mdl/executor/cmd_microflows_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ type flowBuilder struct {
// just emitted an activity, so the next flow's OriginConnectionIndex can
// be overridden by the user. Cleared after each flow is created.
previousStmtAnchor *ast.FlowAnchors
variableAliases map[string]string
outputVarPositions map[string]model.Point
}

// addError records a validation error during flow building.
Expand Down Expand Up @@ -133,6 +135,52 @@ func (fb *flowBuilder) registerResultVariableType(varName string, dt microflows.
}
}

func (fb *flowBuilder) resolveVariableName(varName string) string {
if varName == "" || fb.variableAliases == nil {
return varName
}
if alias := fb.variableAliases[varName]; alias != "" {
return alias
}
return varName
}

func (fb *flowBuilder) resolveVariablePath(path string) string {
if before, after, ok := strings.Cut(path, "/"); ok {
return fb.resolveVariableName(before) + "/" + after
}
return fb.resolveVariableName(path)
}

func (fb *flowBuilder) uniqueImplicitOutputVariable(varName string) string {
if varName == "" {
return ""
}
if fb.outputVarPositions == nil {
fb.outputVarPositions = make(map[string]model.Point)
}
position := model.Point{X: fb.posX, Y: fb.posY}
if previous, ok := fb.outputVarPositions[varName]; ok &&
previous.X == position.X && previous.Y == position.Y &&
fb.isVariableDeclared(varName) {
if fb.variableAliases == nil {
fb.variableAliases = make(map[string]string)
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s_%d", varName, i)
if !fb.isVariableDeclared(candidate) {
fb.variableAliases[varName] = candidate
return candidate
}
}
}
if fb.variableAliases != nil {
delete(fb.variableAliases, varName)
}
fb.outputVarPositions[varName] = position
return varName
}

// lookupMicroflowReturnType resolves the return type of a called microflow by
// qualified name so downstream activities can infer variable types.
func (fb *flowBuilder) lookupMicroflowReturnType(qualifiedName string) microflows.DataType {
Expand Down Expand Up @@ -231,10 +279,12 @@ func (fb *flowBuilder) resolveAssociationPaths(expr ast.Expression) ast.Expressi
}

switch e := expr.(type) {
case *ast.VariableExpr:
return &ast.VariableExpr{Name: fb.resolveVariableName(e.Name)}
case *ast.AttributePathExpr:
resolved := fb.resolvePathSegments(e.Path)
return &ast.AttributePathExpr{
Variable: e.Variable,
Variable: fb.resolveVariableName(e.Variable),
Path: resolved,
Segments: e.Segments,
}
Expand Down
38 changes: 21 additions & 17 deletions mdl/executor/cmd_microflows_builder_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,17 @@ func (fb *flowBuilder) addCreateVariableAction(s *ast.DeclareStmt) model.ID {

// addChangeVariableAction creates a SET statement as a ChangeVariableAction.
func (fb *flowBuilder) addChangeVariableAction(s *ast.MfSetStmt) model.ID {
target := fb.resolveVariablePath(s.Target)
// Validate that the variable has been declared
if !fb.isVariableDeclared(s.Target) {
if !fb.isVariableDeclared(target) {
fb.addErrorWithExample(
fmt.Sprintf("variable '%s' is not declared", s.Target),
errorExampleDeclareVariable(s.Target))
fmt.Sprintf("variable '%s' is not declared", target),
errorExampleDeclareVariable(target))
}

action := &microflows.ChangeVariableAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
VariableName: s.Target,
VariableName: target,
Value: fb.exprToString(s.Value),
}

Expand All @@ -86,9 +87,10 @@ func (fb *flowBuilder) addChangeVariableAction(s *ast.MfSetStmt) model.ID {

// addCreateObjectAction creates a CREATE OBJECT statement.
func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID {
outputVariable := fb.uniqueImplicitOutputVariable(s.Variable)
action := &microflows.CreateObjectAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
OutputVariable: s.Variable,
OutputVariable: outputVariable,
Commit: microflows.CommitTypeNo,
}
// Set entity reference as qualified name (BY_NAME_REFERENCE)
Expand All @@ -100,7 +102,7 @@ func (fb *flowBuilder) addCreateObjectAction(s *ast.CreateObjectStmt) model.ID {

// Register variable type for CHANGE statements
if fb.varTypes != nil && entityQN != "" {
fb.varTypes[s.Variable] = entityQN
fb.varTypes[outputVariable] = entityQN
}

// Build InitialMembers for each SET assignment
Expand Down Expand Up @@ -239,17 +241,18 @@ func (fb *flowBuilder) addRollbackAction(s *ast.RollbackStmt) model.ID {

// addChangeObjectAction creates a CHANGE statement.
func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
changeVariable := fb.resolveVariableName(s.Variable)
action := &microflows.ChangeObjectAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
ChangeVariable: s.Variable,
ChangeVariable: changeVariable,
Commit: microflows.CommitTypeNo,
RefreshInClient: false,
}

// Look up entity type from variable scope
entityQN := ""
if fb.varTypes != nil {
entityQN = fb.varTypes[s.Variable]
entityQN = fb.varTypes[changeVariable]
}

// Build MemberChange items for each SET assignment
Expand Down Expand Up @@ -283,6 +286,7 @@ func (fb *flowBuilder) addChangeObjectAction(s *ast.ChangeObjectStmt) model.ID {
// addRetrieveAction creates a RETRIEVE statement.
func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
var source microflows.RetrieveSource
outputVariable := fb.uniqueImplicitOutputVariable(s.Variable)

if s.StartVariable != "" {
// Association retrieve: RETRIEVE $List FROM $Parent/Module.AssocName
Expand All @@ -296,7 +300,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
assocInfo := fb.lookupAssociation(s.Source.Module, s.Source.Name)
startVarType := ""
if fb.varTypes != nil {
startVarType = fb.varTypes[s.StartVariable]
startVarType = fb.varTypes[fb.resolveVariableName(s.StartVariable)]
}

if assocInfo != nil && assocInfo.Type == domainmodel.AssociationTypeReference &&
Expand All @@ -306,17 +310,17 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
dbSource := &microflows.DatabaseRetrieveSource{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
EntityQualifiedName: assocInfo.parentEntityQN,
XPathConstraint: "[" + assocQN + " = $" + s.StartVariable + "]",
XPathConstraint: "[" + assocQN + " = $" + fb.resolveVariableName(s.StartVariable) + "]",
}
source = dbSource
if fb.varTypes != nil {
fb.varTypes[s.Variable] = "List of " + assocInfo.parentEntityQN
fb.varTypes[outputVariable] = "List of " + assocInfo.parentEntityQN
}
} else {
// Forward traversal or ReferenceSet: use AssociationRetrieveSource
source = &microflows.AssociationRetrieveSource{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
StartVariable: s.StartVariable,
StartVariable: fb.resolveVariableName(s.StartVariable),
AssociationQualifiedName: assocQN,
}
if fb.varTypes != nil {
Expand All @@ -326,10 +330,10 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
if startVarType == assocInfo.childEntityQN {
otherEntity = assocInfo.parentEntityQN
}
fb.varTypes[s.Variable] = otherEntity
fb.varTypes[outputVariable] = otherEntity
} else {
// ReferenceSet or unknown: returns a list
fb.varTypes[s.Variable] = "List of " + assocQN
fb.varTypes[outputVariable] = "List of " + assocQN
}
}
}
Expand Down Expand Up @@ -403,17 +407,17 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID {
if fb.varTypes != nil {
if s.Limit == "1" {
// LIMIT 1 returns a single entity
fb.varTypes[s.Variable] = entityQN
fb.varTypes[outputVariable] = entityQN
} else {
// No LIMIT or LIMIT > 1 returns a list
fb.varTypes[s.Variable] = "List of " + entityQN
fb.varTypes[outputVariable] = "List of " + entityQN
}
}
}

action := &microflows.RetrieveAction{
BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())},
OutputVariable: s.Variable,
OutputVariable: outputVariable,
Source: source,
}

Expand Down
Loading
Loading