Skip to content

Commit cf6860c

Browse files
authored
Merge pull request #260 from hjotha/submit/microflow-builtin-function-case
fix: normalise built-in Mendix function case in expression roundtrip
2 parents e744810 + 8833afa commit cf6860c

2 files changed

Lines changed: 219 additions & 2 deletions

File tree

mdl/executor/cmd_microflows_helpers.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,103 @@ func convertASTToMicroflowDataType(dt ast.DataType, entityResolver func(ast.Qual
6868
}
6969
}
7070

71+
// mendixBuiltinFunctions is the canonical spelling of every built-in Mendix
72+
// expression function. The expression runtime is case-sensitive: it only
73+
// recognises these names as spelt here (lower-case with camelCase for
74+
// compound words). Emitting an alternative spelling causes CE0117
75+
// ("Error(s) in expression.") on Studio Pro validation.
76+
//
77+
// Source: https://docs.mendix.com/refguide/expressions/ and the linked
78+
// function-specific pages (string, math, date arithmetic, parse/format,
79+
// trim-to-date, list operations, aggregates, type conversions).
80+
//
81+
// The map key is the upper-case spelling for case-insensitive lookup; the
82+
// value is the runtime-accepted canonical spelling. Custom user-defined
83+
// java actions, sub-microflows, and unknown function names pass through
84+
// unchanged so user case is preserved.
85+
var mendixBuiltinFunctions = func() map[string]string {
86+
canonical := []string{
87+
// List operations
88+
"head", "tail", "find", "filter", "sort", "union",
89+
"intersect", "subtract", "contains", "equals", "range",
90+
// List aggregates
91+
"count", "sum", "average", "minimum", "maximum",
92+
"allTrue", "anyTrue",
93+
// String functions (docs.mendix.com/refguide/string-function-calls)
94+
"toUpperCase", "toLowerCase", "trim", "length", "substring",
95+
"findLast", "replaceAll", "replaceFirst", "startsWith", "endsWith",
96+
"isMatch", "isInvariantMatch", "stringFromRegex", "stringListFromRegex",
97+
"urlEncode", "urlDecode", "reverse", "indexOf",
98+
// Math functions (docs.mendix.com/refguide/mathematical-function-calls)
99+
"abs", "ceil", "floor", "round", "max", "min", "pow",
100+
"sqrt", "ln", "log10", "random", "rand",
101+
// Date creation (docs.mendix.com/refguide/date-creation)
102+
"dateTime", "dateTimeUTC",
103+
// Begin-of-date / end-of-date / trim-to-date
104+
"trimToDays", "trimToHours", "trimToMinutes", "trimToSeconds",
105+
"trimToDaysUTC", "trimToHoursUTC", "trimToMinutesUTC", "trimToSecondsUTC",
106+
"beginOfDay", "beginOfWeek", "beginOfMonth", "beginOfYear",
107+
"beginOfDayUTC", "beginOfWeekUTC", "beginOfMonthUTC", "beginOfYearUTC",
108+
"endOfDay", "endOfWeek", "endOfMonth", "endOfYear",
109+
"endOfDayUTC", "endOfWeekUTC", "endOfMonthUTC", "endOfYearUTC",
110+
// Between-date functions
111+
"millisecondsBetween", "secondsBetween", "minutesBetween",
112+
"hoursBetween", "daysBetween", "weeksBetween", "monthsBetween",
113+
"yearsBetween", "calendarDaysBetween", "calendarMonthsBetween",
114+
"calendarYearsBetween",
115+
// Add-date functions
116+
"addMilliseconds", "addSeconds", "addMinutes", "addHours",
117+
"addDays", "addWeeks", "addMonths", "addYears",
118+
"addDaysUTC", "addWeeksUTC", "addMonthsUTC", "addYearsUTC",
119+
// Subtract-date functions
120+
"subtractMilliseconds", "subtractSeconds", "subtractMinutes",
121+
"subtractHours", "subtractDays", "subtractWeeks", "subtractMonths",
122+
"subtractYears", "subtractDaysUTC", "subtractWeeksUTC",
123+
"subtractMonthsUTC", "subtractYearsUTC",
124+
// Day-of / timestamp conversion helpers
125+
"dayOfWeek", "dayOfWeekFromDateTime", "weekOfYearFromDateTime",
126+
"dayOfYearFromDateTime", "daysInMonth", "daysInYear",
127+
"dateTimeToEpoch", "epochToDateTime",
128+
// Parse / format (parse-and-format-date, parse-and-format-decimal)
129+
"formatDateTime", "formatDateTimeUTC", "parseDateTime", "parseDateTimeUTC",
130+
"parseInteger", "parseLong", "parseDecimal", "formatDecimal",
131+
// To-string / length (to-string, length refguide pages)
132+
"toString", "toBoolean", "toFloat",
133+
// Enumeration helpers
134+
"getCaption", "getKey",
135+
// Miscellaneous
136+
"if", "empty", "isNew", "isAnonymous",
137+
// Boolean operators expressed as functions (true(), false())
138+
"true", "false",
139+
// Not / and / or appear as operators, not function calls — omitted.
140+
}
141+
m := make(map[string]string, len(canonical))
142+
for _, c := range canonical {
143+
m[strings.ToUpper(c)] = c
144+
}
145+
return m
146+
}()
147+
148+
// mendixFunctionName normalises the case of built-in Mendix expression
149+
// functions. The visitor canonicalises list / aggregate operations in
150+
// UPPERCASE for AST dispatch; the expression runtime only recognises the
151+
// documented camelCase spelling. For every built-in Mendix function we
152+
// always emit the canonical spelling so that:
153+
//
154+
// - round-tripping a pristine microflow never mutates `find(...)` into
155+
// `FIND(...)` (which Studio Pro rejects with CE0117).
156+
// - LLM-generated MDL with accidental capitalisation (`LENGTH(...)`,
157+
// `ToString(...)`) still validates when executed.
158+
//
159+
// Custom (user-defined) java actions, sub-microflows and entity member
160+
// references pass through unchanged so user case is preserved.
161+
func mendixFunctionName(name string) string {
162+
if canonical, ok := mendixBuiltinFunctions[strings.ToUpper(name)]; ok {
163+
return canonical
164+
}
165+
return name
166+
}
167+
71168
// expressionToString converts an AST Expression to a Mendix expression string.
72169
func expressionToString(expr ast.Expression) string {
73170
// Check for nil interface
@@ -119,7 +216,7 @@ func expressionToString(expr ast.Expression) string {
119216
for _, arg := range e.Arguments {
120217
args = append(args, expressionToString(arg))
121218
}
122-
return e.Name + "(" + strings.Join(args, ", ") + ")"
219+
return mendixFunctionName(e.Name) + "(" + strings.Join(args, ", ") + ")"
123220
case *ast.TokenExpr:
124221
return "[%" + e.Token + "%]"
125222
case *ast.ParenExpr:
@@ -181,7 +278,7 @@ func expressionToXPath(expr ast.Expression) string {
181278
for _, arg := range e.Arguments {
182279
args = append(args, expressionToXPath(arg))
183280
}
184-
return e.Name + "(" + strings.Join(args, ", ") + ")"
281+
return mendixFunctionName(e.Name) + "(" + strings.Join(args, ", ") + ")"
185282
case *ast.LiteralExpr:
186283
if e.Kind == ast.LiteralEmpty {
187284
return "empty"

mdl/executor/cmd_microflows_helpers_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,123 @@ func TestExpressionToString_QualifiedNameUnchanged(t *testing.T) {
7373
t.Errorf("expressionToString = %q, want %q", got, want)
7474
}
7575
}
76+
77+
// TestMendixFunctionName_CanonicalSpelling ensures every built-in Mendix
78+
// expression function is emitted with the runtime-accepted camelCase
79+
// spelling regardless of how the AST stores the name. The visitor
80+
// upper-cases list / aggregate operations for dispatch (e.g. FIND, COUNT);
81+
// without normalisation the writer would emit `FIND(...)` which the Mendix
82+
// expression runtime rejects with CE0117. This test pins the canonical
83+
// spelling for the full documented built-in set.
84+
//
85+
// References:
86+
// - https://docs.mendix.com/refguide/expressions/
87+
// - https://docs.mendix.com/refguide/string-function-calls/
88+
// - https://docs.mendix.com/refguide/mathematical-function-calls/
89+
// - https://docs.mendix.com/refguide/add-date-function-calls/
90+
// - https://docs.mendix.com/refguide/between-date-function-calls/
91+
// - https://docs.mendix.com/refguide/parse-and-format-date-function-calls/
92+
// - https://docs.mendix.com/refguide/parse-and-format-decimal-function-calls/
93+
func TestMendixFunctionName_CanonicalSpelling(t *testing.T) {
94+
cases := []struct {
95+
input string
96+
want string
97+
}{
98+
// List operations: visitor emits UPPERCASE, runtime requires lowercase
99+
{"HEAD", "head"}, {"TAIL", "tail"}, {"FIND", "find"},
100+
{"FILTER", "filter"}, {"SORT", "sort"}, {"UNION", "union"},
101+
{"INTERSECT", "intersect"}, {"SUBTRACT", "subtract"},
102+
{"CONTAINS", "contains"}, {"EQUALS", "equals"}, {"RANGE", "range"},
103+
// Aggregates: visitor maps AVG/MIN/MAX to AVERAGE/MINIMUM/MAXIMUM
104+
{"COUNT", "count"}, {"SUM", "sum"},
105+
{"AVERAGE", "average"}, {"MINIMUM", "minimum"}, {"MAXIMUM", "maximum"},
106+
// String functions
107+
{"LENGTH", "length"}, {"Length", "length"}, {"length", "length"},
108+
{"SUBSTRING", "substring"}, {"ToUpperCase", "toUpperCase"},
109+
{"TOLOWERCASE", "toLowerCase"}, {"TRIM", "trim"},
110+
{"FINDLAST", "findLast"}, {"REPLACEALL", "replaceAll"},
111+
{"STARTSWITH", "startsWith"}, {"ENDSWITH", "endsWith"},
112+
{"ISMATCH", "isMatch"}, {"URLENCODE", "urlEncode"},
113+
// Math
114+
{"ABS", "abs"}, {"CEIL", "ceil"}, {"FLOOR", "floor"},
115+
{"ROUND", "round"}, {"POW", "pow"}, {"SQRT", "sqrt"},
116+
// Parse / format
117+
{"PARSEINTEGER", "parseInteger"}, {"ParseInteger", "parseInteger"},
118+
{"PARSEDECIMAL", "parseDecimal"}, {"PARSELONG", "parseLong"},
119+
{"FORMATDECIMAL", "formatDecimal"}, {"FORMATDATETIME", "formatDateTime"},
120+
{"PARSEDATETIME", "parseDateTime"}, {"PARSEDATETIMEUTC", "parseDateTimeUTC"},
121+
{"TOSTRING", "toString"}, {"TOBOOLEAN", "toBoolean"},
122+
// Add / subtract / between date
123+
{"ADDDAYS", "addDays"}, {"ADDMONTHS", "addMonths"},
124+
{"SUBTRACTDAYS", "subtractDays"},
125+
{"DAYSBETWEEN", "daysBetween"}, {"SECONDSBETWEEN", "secondsBetween"},
126+
{"MILLISECONDSBETWEEN", "millisecondsBetween"},
127+
// Begin/end/trim-to date
128+
{"BEGINOFDAY", "beginOfDay"}, {"ENDOFMONTH", "endOfMonth"},
129+
{"TRIMTODAYS", "trimToDays"}, {"TRIMTOMINUTES", "trimToMinutes"},
130+
// Misc
131+
{"EMPTY", "empty"}, {"ISNEW", "isNew"},
132+
{"GETCAPTION", "getCaption"}, {"GETKEY", "getKey"},
133+
// Custom user-defined names pass through unchanged (no normalisation)
134+
{"MyJavaAction", "MyJavaAction"},
135+
{"MyModule_DoSomething", "MyModule_DoSomething"},
136+
{"SOMEUNKNOWNFUNC", "SOMEUNKNOWNFUNC"},
137+
}
138+
for _, tc := range cases {
139+
got := mendixFunctionName(tc.input)
140+
if got != tc.want {
141+
t.Errorf("mendixFunctionName(%q) = %q, want %q", tc.input, got, tc.want)
142+
}
143+
}
144+
}
145+
146+
// TestExpressionToString_FunctionCallCaseFixed ensures that a FunctionCallExpr
147+
// built from a list operation (visitor canonicalises FIND in uppercase) is
148+
// serialised with the Mendix-accepted lowercase spelling, with arguments
149+
// separated by ", " to match the pristine BSON serialisation format.
150+
func TestExpressionToString_FunctionCallCaseFixed(t *testing.T) {
151+
expr := &ast.FunctionCallExpr{
152+
Name: "FIND",
153+
Arguments: []ast.Expression{
154+
&ast.VariableExpr{Name: "DisplayVersion"},
155+
&ast.LiteralExpr{Value: ".", Kind: ast.LiteralString},
156+
},
157+
}
158+
got := expressionToString(expr)
159+
want := "find($DisplayVersion, '.')"
160+
if got != want {
161+
t.Errorf("expressionToString = %q, want %q", got, want)
162+
}
163+
}
164+
165+
// TestExpressionToString_NestedBuiltins exercises the common parseInteger(
166+
// substring(..., find(..., '.'))) pattern found in pristine Mendix microflows
167+
// (Apps.GetOrCreateMendixVersionFromString). Before the fix mxcli emitted
168+
// `FIND` uppercase here, triggering CE0117 on roundtrip.
169+
func TestExpressionToString_NestedBuiltins(t *testing.T) {
170+
// parseInteger(substring($DisplayVersion, 0, find($DisplayVersion, '.')))
171+
findCall := &ast.FunctionCallExpr{
172+
Name: "FIND",
173+
Arguments: []ast.Expression{
174+
&ast.VariableExpr{Name: "DisplayVersion"},
175+
&ast.LiteralExpr{Value: ".", Kind: ast.LiteralString},
176+
},
177+
}
178+
substringCall := &ast.FunctionCallExpr{
179+
Name: "substring",
180+
Arguments: []ast.Expression{
181+
&ast.VariableExpr{Name: "DisplayVersion"},
182+
&ast.LiteralExpr{Value: int64(0), Kind: ast.LiteralInteger},
183+
findCall,
184+
},
185+
}
186+
parseInt := &ast.FunctionCallExpr{
187+
Name: "parseInteger",
188+
Arguments: []ast.Expression{substringCall},
189+
}
190+
got := expressionToString(parseInt)
191+
want := "parseInteger(substring($DisplayVersion, 0, find($DisplayVersion, '.')))"
192+
if got != want {
193+
t.Errorf("expressionToString = %q, want %q", got, want)
194+
}
195+
}

0 commit comments

Comments
 (0)