Skip to content

Commit ab3fe2a

Browse files
akoclaude
andcommitted
Fix quoted identifiers not resolved in page widget references (issue #8)
Quoted identifiers like MaisonElegance."Collection" failed in page widget contexts (DataSource, SHOW_PAGE, MICROFLOW, Snippet) because the visitor used qn.GetText() which preserves raw quotes, and the executor split on "." without stripping them. Fix: use getQualifiedNameText(qn) in visitor_page_v3.go to strip quotes at parse time, and add unquoteQualifiedName() safety net in executor resolve functions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dbaab80 commit ab3fe2a

5 files changed

Lines changed: 187 additions & 10 deletions

File tree

mdl/executor/cmd_pages_builder_input.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ import (
1414
"go.mongodb.org/mongo-driver/bson/primitive"
1515
)
1616

17+
// unquoteIdentifier strips surrounding double-quotes or backticks from a quoted identifier.
18+
func unquoteIdentifier(s string) string {
19+
if len(s) >= 2 {
20+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '`' && s[len(s)-1] == '`') {
21+
return s[1 : len(s)-1]
22+
}
23+
}
24+
return s
25+
}
26+
27+
// unquoteQualifiedName strips quotes from each segment of a dotted qualified name.
28+
func unquoteQualifiedName(s string) string {
29+
parts := strings.Split(s, ".")
30+
for i, p := range parts {
31+
parts[i] = unquoteIdentifier(p)
32+
}
33+
return strings.Join(parts, ".")
34+
}
35+
1736
// resolveAttributePath resolves a short attribute name to a fully qualified name
1837
// using the current entity context. If the attribute already has dots or no entity
1938
// context is available, the attribute is returned as-is.
@@ -213,6 +232,7 @@ func (pb *pageBuilder) resolveSnippetRef(snippetRef string) (model.ID, error) {
213232
return "", fmt.Errorf("empty snippet reference")
214233
}
215234

235+
snippetRef = unquoteQualifiedName(snippetRef)
216236
parts := strings.Split(snippetRef, ".")
217237
var moduleName, snippetName string
218238
if len(parts) >= 2 {
@@ -244,6 +264,7 @@ func (pb *pageBuilder) resolveSnippetRef(snippetRef string) (model.ID, error) {
244264
}
245265

246266
func (pb *pageBuilder) resolveMicroflow(qualifiedName string) (model.ID, error) {
267+
qualifiedName = unquoteQualifiedName(qualifiedName)
247268
// Parse qualified name
248269
parts := strings.Split(qualifiedName, ".")
249270
if len(parts) < 2 {
@@ -289,6 +310,7 @@ func (pb *pageBuilder) resolvePageRef(pageRef string) (model.ID, error) {
289310
return "", fmt.Errorf("empty page reference")
290311
}
291312

313+
pageRef = unquoteQualifiedName(pageRef)
292314
parts := strings.Split(pageRef, ".")
293315
var moduleName, pageName string
294316
if len(parts) >= 2 {

mdl/executor/cmd_pages_builder_v3.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,7 @@ func (pb *pageBuilder) buildClientActionV3(action *ast.ActionV3) (pages.ClientAc
856856
// =============================================================================
857857

858858
func (pb *pageBuilder) extractModule(qualifiedName string) string {
859+
qualifiedName = unquoteQualifiedName(qualifiedName)
859860
parts := strings.Split(qualifiedName, ".")
860861
if len(parts) >= 2 {
861862
return parts[0]
@@ -864,6 +865,7 @@ func (pb *pageBuilder) extractModule(qualifiedName string) string {
864865
}
865866

866867
func (pb *pageBuilder) extractName(qualifiedName string) string {
868+
qualifiedName = unquoteQualifiedName(qualifiedName)
867869
parts := strings.Split(qualifiedName, ".")
868870
if len(parts) >= 2 {
869871
return parts[1]

mdl/executor/unquote_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import "testing"
6+
7+
func TestUnquoteQualifiedName(t *testing.T) {
8+
tests := []struct {
9+
input, want string
10+
}{
11+
{"Module.Entity", "Module.Entity"},
12+
{`Module."Entity"`, "Module.Entity"},
13+
{`"Module"."Entity"`, "Module.Entity"},
14+
{`MaisonElegance."Collection"`, "MaisonElegance.Collection"},
15+
{`MaisonElegance."FormSubmissionStatus".StatusNew`, "MaisonElegance.FormSubmissionStatus.StatusNew"},
16+
{"SimpleName", "SimpleName"},
17+
{`"QuotedOnly"`, "QuotedOnly"},
18+
{"", ""},
19+
}
20+
for _, tt := range tests {
21+
got := unquoteQualifiedName(tt.input)
22+
if got != tt.want {
23+
t.Errorf("unquoteQualifiedName(%q) = %q, want %q", tt.input, got, tt.want)
24+
}
25+
}
26+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package visitor
4+
5+
import (
6+
"testing"
7+
8+
"github.com/mendixlabs/mxcli/mdl/ast"
9+
)
10+
11+
func TestQuotedIdentifiersInPageWidgets(t *testing.T) {
12+
input := `CREATE PAGE MaisonElegance."Collection_Overview" (
13+
Layout: Atlas_Core."Atlas_Default"
14+
) {
15+
DATAVIEW dv (DataSource: DATABASE FROM MaisonElegance."Collection") {
16+
TEXTBOX txtName (Attribute: Name, Label: 'Name')
17+
ACTIONBUTTON btnEdit (
18+
Caption: 'Edit',
19+
Action: SHOW_PAGE MaisonElegance."Collection_NewEdit"
20+
)
21+
ACTIONBUTTON btnRun (
22+
Caption: 'Run',
23+
Action: MICROFLOW MaisonElegance."ACT_Collection_Run"
24+
)
25+
}
26+
SNIPPETCALL sc (Snippet: MaisonElegance."Footer_Snippet")
27+
};`
28+
29+
prog, errs := Build(input)
30+
if len(errs) > 0 {
31+
for _, err := range errs {
32+
t.Errorf("Parse error: %v", err)
33+
}
34+
t.FailNow()
35+
}
36+
37+
if len(prog.Statements) != 1 {
38+
t.Fatalf("Expected 1 statement, got %d", len(prog.Statements))
39+
}
40+
41+
stmt, ok := prog.Statements[0].(*ast.CreatePageStmtV3)
42+
if !ok {
43+
t.Fatalf("Expected CreatePageStmtV3, got %T", prog.Statements[0])
44+
}
45+
46+
// Layout should be unquoted
47+
if stmt.Layout != "Atlas_Core.Atlas_Default" {
48+
t.Errorf("Layout: expected 'Atlas_Core.Atlas_Default', got %q", stmt.Layout)
49+
}
50+
51+
// Page name should be unquoted
52+
if stmt.Name.Module != "MaisonElegance" || stmt.Name.Name != "Collection_Overview" {
53+
t.Errorf("Page name: expected MaisonElegance.Collection_Overview, got %s.%s", stmt.Name.Module, stmt.Name.Name)
54+
}
55+
56+
// Find the DataView and check DataSource entity reference is unquoted
57+
if len(stmt.Widgets) < 1 {
58+
t.Fatal("Expected at least 1 child widget")
59+
}
60+
dv := stmt.Widgets[0]
61+
if dv.GetDataSource() == nil {
62+
t.Fatal("DataView DataSource is nil")
63+
}
64+
if dv.GetDataSource().Reference != "MaisonElegance.Collection" {
65+
t.Errorf("DataSource.Reference: expected 'MaisonElegance.Collection', got %q", dv.GetDataSource().Reference)
66+
}
67+
68+
// Find SHOW_PAGE action button and check target is unquoted
69+
btnEdit := findChildByName(dv, "btnEdit")
70+
if btnEdit == nil {
71+
t.Fatal("btnEdit widget not found")
72+
}
73+
action := btnEdit.GetAction()
74+
if action == nil {
75+
t.Fatal("btnEdit Action is nil")
76+
}
77+
if action.Target != "MaisonElegance.Collection_NewEdit" {
78+
t.Errorf("SHOW_PAGE target: expected 'MaisonElegance.Collection_NewEdit', got %q", action.Target)
79+
}
80+
81+
// Find MICROFLOW action button and check target is unquoted
82+
btnRun := findChildByName(dv, "btnRun")
83+
if btnRun == nil {
84+
t.Fatal("btnRun widget not found")
85+
}
86+
runAction := btnRun.GetAction()
87+
if runAction == nil {
88+
t.Fatal("btnRun Action is nil")
89+
}
90+
if runAction.Target != "MaisonElegance.ACT_Collection_Run" {
91+
t.Errorf("MICROFLOW target: expected 'MaisonElegance.ACT_Collection_Run', got %q", runAction.Target)
92+
}
93+
94+
// Find SNIPPETCALL and check snippet reference is unquoted
95+
sc := findChildByName2(stmt.Widgets, "sc")
96+
if sc == nil {
97+
t.Fatal("sc (SnippetCall) widget not found")
98+
}
99+
snippetRef, ok := sc.Properties["Snippet"].(string)
100+
if !ok {
101+
t.Fatal("Snippet property not a string")
102+
}
103+
if snippetRef != "MaisonElegance.Footer_Snippet" {
104+
t.Errorf("Snippet ref: expected 'MaisonElegance.Footer_Snippet', got %q", snippetRef)
105+
}
106+
}
107+
108+
func findChildByName(parent *ast.WidgetV3, name string) *ast.WidgetV3 {
109+
for _, c := range parent.Children {
110+
if c.Name == name {
111+
return c
112+
}
113+
}
114+
return nil
115+
}
116+
117+
func findChildByName2(widgets []*ast.WidgetV3, name string) *ast.WidgetV3 {
118+
for _, w := range widgets {
119+
if w.Name == name {
120+
return w
121+
}
122+
if found := findChildByName(w, name); found != nil {
123+
return found
124+
}
125+
}
126+
return nil
127+
}

mdl/visitor/visitor_page_v3.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ func (b *Builder) parsePageHeaderV3(ctx parser.IPageHeaderV3Context, stmt *ast.C
9797
} else if prop.LAYOUT() != nil {
9898
// Layout: Atlas_Core.Atlas_Default or 'Layout Name'
9999
if qn := prop.QualifiedName(); qn != nil {
100-
stmt.Layout = qn.GetText()
100+
stmt.Layout = getQualifiedNameText(qn)
101101
} else if str := prop.STRING_LITERAL(); str != nil {
102102
stmt.Layout = unquoteString(str.GetText())
103103
}
@@ -489,7 +489,7 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg
489489
// Snippet: ...
490490
if propCtx.SNIPPET() != nil {
491491
if qn := propCtx.QualifiedName(); qn != nil {
492-
widget.Properties["Snippet"] = qn.GetText()
492+
widget.Properties["Snippet"] = getQualifiedNameText(qn)
493493
}
494494
return
495495
}
@@ -579,7 +579,7 @@ func buildDataSourceV3(ctx parser.IDataSourceExprV3Context) *ast.DataSourceV3 {
579579
// DATABASE [FROM] Entity [WHERE ...] [SORT BY ...]
580580
ds.Type = "database"
581581
if qn := dsCtx.QualifiedName(); qn != nil {
582-
ds.Reference = qn.GetText()
582+
ds.Reference = getQualifiedNameText(qn)
583583
}
584584

585585
// Inline WHERE clause
@@ -602,7 +602,7 @@ func buildDataSourceV3(ctx parser.IDataSourceExprV3Context) *ast.DataSourceV3 {
602602
// MICROFLOW Module.Flow
603603
ds.Type = "microflow"
604604
if qn := dsCtx.QualifiedName(); qn != nil {
605-
ds.Reference = qn.GetText()
605+
ds.Reference = getQualifiedNameText(qn)
606606
}
607607
if argsCtx := dsCtx.MicroflowArgsV3(); argsCtx != nil {
608608
ds.Args = buildMicroflowArgsV3(argsCtx)
@@ -611,7 +611,7 @@ func buildDataSourceV3(ctx parser.IDataSourceExprV3Context) *ast.DataSourceV3 {
611611
// NANOFLOW Module.Flow
612612
ds.Type = "nanoflow"
613613
if qn := dsCtx.QualifiedName(); qn != nil {
614-
ds.Reference = qn.GetText()
614+
ds.Reference = getQualifiedNameText(qn)
615615
}
616616
if argsCtx := dsCtx.MicroflowArgsV3(); argsCtx != nil {
617617
ds.Args = buildMicroflowArgsV3(argsCtx)
@@ -657,7 +657,7 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 {
657657
} else if actCtx.CREATE_OBJECT() != nil {
658658
action.Type = "create"
659659
if qn := actCtx.QualifiedName(); qn != nil {
660-
action.Target = qn.GetText()
660+
action.Target = getQualifiedNameText(qn)
661661
}
662662
// Check for THEN action
663663
if thenCtx := actCtx.ActionExprV3(); thenCtx != nil {
@@ -666,23 +666,23 @@ func buildActionV3(ctx parser.IActionExprV3Context) *ast.ActionV3 {
666666
} else if actCtx.SHOW_PAGE() != nil {
667667
action.Type = "showPage"
668668
if qn := actCtx.QualifiedName(); qn != nil {
669-
action.Target = qn.GetText()
669+
action.Target = getQualifiedNameText(qn)
670670
}
671671
if argsCtx := actCtx.MicroflowArgsV3(); argsCtx != nil {
672672
action.Args = buildMicroflowArgsV3(argsCtx)
673673
}
674674
} else if actCtx.MICROFLOW() != nil {
675675
action.Type = "microflow"
676676
if qn := actCtx.QualifiedName(); qn != nil {
677-
action.Target = qn.GetText()
677+
action.Target = getQualifiedNameText(qn)
678678
}
679679
if argsCtx := actCtx.MicroflowArgsV3(); argsCtx != nil {
680680
action.Args = buildMicroflowArgsV3(argsCtx)
681681
}
682682
} else if actCtx.NANOFLOW() != nil {
683683
action.Type = "nanoflow"
684684
if qn := actCtx.QualifiedName(); qn != nil {
685-
action.Target = qn.GetText()
685+
action.Target = getQualifiedNameText(qn)
686686
}
687687
if argsCtx := actCtx.MicroflowArgsV3(); argsCtx != nil {
688688
action.Args = buildMicroflowArgsV3(argsCtx)
@@ -936,7 +936,7 @@ func buildPropertyValueV3(ctx parser.IPropertyValueV3Context) any {
936936
return strings.EqualFold(bl.GetText(), "true")
937937
}
938938
if qn := pvCtx.QualifiedName(); qn != nil {
939-
return qn.GetText()
939+
return getQualifiedNameText(qn)
940940
}
941941
if id := pvCtx.IDENTIFIER(); id != nil {
942942
return id.GetText()

0 commit comments

Comments
 (0)