Skip to content

Commit d871691

Browse files
committed
Merge branch 'issues'
2 parents 6a6e5ca + 97a829d commit d871691

15 files changed

Lines changed: 11095 additions & 10094 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- Bug test: SNIPPETCALL on a parameterized snippet without Params should error.
2+
-- Issue #291: mxcli silently wrote a corrupt PageParameterMapping with null Variable.
3+
-- After the fix: mxcli returns a validation error when Params are omitted.
4+
--
5+
-- This script is intentionally invalid and must produce a parse/validation error.
6+
-- Run with: mxcli check snippetcall-missing-params.mdl
7+
--
8+
-- The correct call is:
9+
-- SNIPPETCALL call1 (Snippet: Mod.MySnippet, Params: {Asset: $Asset})
10+
11+
CREATE SNIPPET Mod.MySnippet (
12+
Params: { $Asset: Mod.Asset },
13+
Folder: 'Snippets'
14+
) {
15+
DYNAMICTEXT txt1 (Content: 'Hello')
16+
};
17+
18+
-- The following page would previously produce a corrupt model.
19+
-- After fix, mxcli must reject this with:
20+
-- snippet Mod.MySnippet requires parameter $Asset — add Params: {Asset: $<variable>} to the SNIPPETCALL
21+
ALTER PAGE Mod.MyPage {
22+
INSERT AFTER someWidget {
23+
DATAVIEW dv1 (DataSource: $Asset) {
24+
SNIPPETCALL call1 (Snippet: Mod.MySnippet)
25+
}
26+
}
27+
};

mdl-examples/doctype-tests/03-page-examples.mdl

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,66 @@ create page PgTest.P016_Page_With_Snippet
11871187
}
11881188
}
11891189

1190+
/**
1191+
* Page that calls a parameterized snippet with Params: mapping.
1192+
* Demonstrates fix for issue #291: SNIPPETCALL with Params syntax.
1193+
*
1194+
* Two forms are accepted:
1195+
* Params: {Customer: $Customer} -- bare parameter name (keywords OK)
1196+
* Params: {$Customer: $Customer} -- dollar-prefixed parameter name
1197+
*/
1198+
create or replace page PgTest.P017_SnippetCall_With_Params
1199+
(
1200+
title: 'Customer Detail with Snippet',
1201+
layout: Atlas_Core.Atlas_Default,
1202+
params: {
1203+
$Customer: PgTest.Customer
1204+
},
1205+
url: 'p017_snippet_with_params',
1206+
folder: 'Snippets'
1207+
)
1208+
{
1209+
layoutgrid mainGrid {
1210+
row row1 {
1211+
column col1 (desktopwidth: 8) {
1212+
-- Call CustomerInfo snippet — bare parameter name form
1213+
snippetcall customerInfoSnippet (
1214+
snippet: PgTest.CustomerInfo,
1215+
params: {Customer: $Customer}
1216+
)
1217+
}
1218+
column col2 (desktopwidth: 4) {
1219+
-- Call CustomerSidebar snippet — dollar-prefixed parameter name form
1220+
snippetcall sidebarSnippet (
1221+
snippet: PgTest.CustomerSidebar,
1222+
params: {$Customer: $Customer}
1223+
)
1224+
}
1225+
}
1226+
}
1227+
}
1228+
1229+
-- MARK: Snippet call keyword param name
1230+
-- Issue #291: param names that are grammar keywords (e.g. Agent, Customer) must
1231+
-- also be accepted in bare form. Verified against test3.mpr.
1232+
-- create or replace page AgentCommons.Test_SnippetCallParams (
1233+
-- title: 'Snippet Params Test',
1234+
-- layout: Atlas_Core.Atlas_Default,
1235+
-- params: { $Agent: AgentCommons.Agent },
1236+
-- folder: 'Private/Pages/Agent'
1237+
-- ) {
1238+
-- layoutgrid mainGrid {
1239+
-- row row1 {
1240+
-- column col1 (desktopwidth: 6) {
1241+
-- snippetcall s1 (snippet: AgentCommons.Snippet_AgentTypeBadge, params: {$Agent: $Agent})
1242+
-- }
1243+
-- column col2 (desktopwidth: 6) {
1244+
-- snippetcall s2 (snippet: AgentCommons.Snippet_AgentTypeBadge, params: {Agent: $Agent})
1245+
-- }
1246+
-- }
1247+
-- }
1248+
-- }
1249+
11901250

11911251

11921252
-- MARK: Master-Detail

mdl/ast/ast_page_v3.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,20 @@ func (w *WidgetV3) GetSnippet() string {
254254
return w.GetStringProp("Snippet")
255255
}
256256

257+
// SnippetCallParam represents one parameter mapping in a SNIPPETCALL Params: block.
258+
type SnippetCallParam struct {
259+
ParamName string // Parameter name as written (may include leading $)
260+
Variable string // Variable being passed, always includes leading $
261+
}
262+
263+
// GetSnippetParams returns the Params mappings for a SNIPPETCALL widget, or nil.
264+
func (w *WidgetV3) GetSnippetParams() []SnippetCallParam {
265+
if v, ok := w.Properties["Params"].([]SnippetCallParam); ok {
266+
return v
267+
}
268+
return nil
269+
}
270+
257271
// GetSelection returns the Selection mode or empty string.
258272
func (w *WidgetV3) GetSelection() string {
259273
return w.GetStringProp("Selection")

mdl/executor/cmd_pages_builder_v3_widgets.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,13 +766,19 @@ func (pb *pageBuilder) buildSnippetCallV3(w *ast.WidgetV3) (*pages.SnippetCallWi
766766
}
767767

768768
// Handle Snippet property - resolve snippet and store both ID and name
769-
if snippetName := w.GetSnippet(); snippetName != "" {
769+
snippetName := w.GetSnippet()
770+
if snippetName != "" {
770771
snippetID, err := pb.resolveSnippetRef(snippetName)
771772
if err != nil {
772773
return nil, mdlerrors.NewBackend(fmt.Sprintf("resolve snippet %s", snippetName), err)
773774
}
774775
sc.SnippetID = snippetID
775776
sc.SnippetName = snippetName // Store qualified name for BY_NAME_REFERENCE serialization
777+
778+
// Validate and wire up parameter mappings.
779+
if err := pb.buildSnippetCallParams(sc, snippetName, w.GetSnippetParams()); err != nil {
780+
return nil, err
781+
}
776782
}
777783

778784
if err := pb.registerWidgetName(w.Name, sc.ID); err != nil {
@@ -782,6 +788,52 @@ func (pb *pageBuilder) buildSnippetCallV3(w *ast.WidgetV3) (*pages.SnippetCallWi
782788
return sc, nil
783789
}
784790

791+
// buildSnippetCallParams validates the supplied param mappings against the
792+
// snippet's declared parameters and populates sc.ParameterMappings.
793+
func (pb *pageBuilder) buildSnippetCallParams(sc *pages.SnippetCallWidget, snippetQName string, supplied []ast.SnippetCallParam) error {
794+
snippets, err := pb.backend.ListSnippets()
795+
if err != nil {
796+
return err
797+
}
798+
799+
// Find the target snippet to read its declared parameters.
800+
var targetSnippet *pages.Snippet
801+
for _, s := range snippets {
802+
if s.Name != "" && (s.Name == snippetQName || strings.HasSuffix(snippetQName, "."+s.Name)) {
803+
targetSnippet = s
804+
break
805+
}
806+
}
807+
if targetSnippet == nil || len(targetSnippet.Parameters) == 0 {
808+
// Snippet has no declared parameters — nothing to validate or map.
809+
return nil
810+
}
811+
812+
// Build a lookup of supplied mappings by parameter name (strip leading $).
813+
suppliedByName := make(map[string]string, len(supplied))
814+
for _, p := range supplied {
815+
name := strings.TrimPrefix(p.ParamName, "$")
816+
suppliedByName[name] = p.Variable
817+
}
818+
819+
// Validate that every declared parameter has a mapping, then build the list.
820+
for _, declared := range targetSnippet.Parameters {
821+
argument, ok := suppliedByName[declared.Name]
822+
if !ok {
823+
return mdlerrors.NewValidationf(
824+
"snippet %s requires parameter $%s — add Params: {%s: $<variable>} to the SNIPPETCALL",
825+
snippetQName, declared.Name, declared.Name,
826+
)
827+
}
828+
sc.ParameterMappings = append(sc.ParameterMappings, pages.SnippetParamMapping{
829+
ParamName: declared.Name,
830+
Argument: argument,
831+
})
832+
}
833+
834+
return nil
835+
}
836+
785837
// buildTemplateV3 creates a Container to hold template content.
786838
func (pb *pageBuilder) buildTemplateV3(w *ast.WidgetV3) (*pages.Container, error) {
787839
container := &pages.Container{
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package executor
4+
5+
import (
6+
"strings"
7+
"testing"
8+
9+
"github.com/mendixlabs/mxcli/mdl/ast"
10+
"github.com/mendixlabs/mxcli/mdl/backend/mock"
11+
"github.com/mendixlabs/mxcli/mdl/types"
12+
"github.com/mendixlabs/mxcli/model"
13+
"github.com/mendixlabs/mxcli/sdk/pages"
14+
)
15+
16+
// mkSnippetWithParam creates a snippet that declares one entity-typed parameter.
17+
func mkSnippetWithParam(containerID model.ID, snippetName, paramName string) *pages.Snippet {
18+
snp := mkSnippet(containerID, snippetName)
19+
snp.Parameters = []*pages.SnippetParameter{
20+
{
21+
BaseElement: model.BaseElement{ID: nextID("sp")},
22+
ContainerID: snp.ID,
23+
Name: paramName,
24+
EntityName: "Mod.Asset",
25+
},
26+
}
27+
return snp
28+
}
29+
30+
// newPageBuilder returns a pageBuilder wired to the given mock backend and hierarchy.
31+
func newPageBuilder(mb *mock.MockBackend, h *ContainerHierarchy, moduleName string) *pageBuilder {
32+
var modID model.ID
33+
for id, name := range h.moduleNames {
34+
if name == moduleName {
35+
modID = id
36+
break
37+
}
38+
}
39+
cache := &executorCache{hierarchy: h}
40+
return &pageBuilder{
41+
backend: mb,
42+
moduleID: modID,
43+
moduleName: moduleName,
44+
widgetScope: make(map[string]model.ID),
45+
paramScope: make(map[string]model.ID),
46+
paramEntityNames: make(map[string]string),
47+
execCache: cache,
48+
widgetBackend: mb,
49+
}
50+
}
51+
52+
// TestSnippetCall_MissingParam_ReturnsError verifies that placing a SNIPPETCALL
53+
// without the required Params yields a validation error (issue #291 — guard).
54+
func TestSnippetCall_MissingParam_ReturnsError(t *testing.T) {
55+
mod := mkModule("Mod")
56+
snp := mkSnippetWithParam(mod.ID, "MySnippet", "Asset")
57+
58+
h := mkHierarchy(mod)
59+
withContainer(h, snp.ContainerID, mod.ID)
60+
61+
mb := &mock.MockBackend{
62+
IsConnectedFunc: func() bool { return true },
63+
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
64+
ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil },
65+
ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snp}, nil },
66+
}
67+
68+
pb := newPageBuilder(mb, h, "Mod")
69+
w := &ast.WidgetV3{
70+
Type: "SNIPPETCALL",
71+
Name: "sc1",
72+
Properties: map[string]any{"Snippet": "Mod.MySnippet"},
73+
}
74+
75+
_, err := pb.buildSnippetCallV3(w)
76+
if err == nil {
77+
t.Fatal("expected validation error for missing snippet parameter, got nil")
78+
}
79+
if !strings.Contains(err.Error(), "Asset") {
80+
t.Errorf("error should mention missing parameter 'Asset', got: %v", err)
81+
}
82+
}
83+
84+
// TestSnippetCall_WithParam_Succeeds verifies that a SNIPPETCALL with correct
85+
// Params mapping passes validation and produces a SnippetCallWidget with mappings.
86+
func TestSnippetCall_WithParam_Succeeds(t *testing.T) {
87+
mod := mkModule("Mod")
88+
snp := mkSnippetWithParam(mod.ID, "MySnippet", "Asset")
89+
90+
h := mkHierarchy(mod)
91+
withContainer(h, snp.ContainerID, mod.ID)
92+
93+
mb := &mock.MockBackend{
94+
IsConnectedFunc: func() bool { return true },
95+
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
96+
ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil },
97+
ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snp}, nil },
98+
}
99+
100+
pb := newPageBuilder(mb, h, "Mod")
101+
w := &ast.WidgetV3{
102+
Type: "SNIPPETCALL",
103+
Name: "sc1",
104+
Properties: map[string]any{
105+
"Snippet": "Mod.MySnippet",
106+
"Params": []ast.SnippetCallParam{{ParamName: "Asset", Variable: "$Asset"}},
107+
},
108+
}
109+
110+
sc, err := pb.buildSnippetCallV3(w)
111+
if err != nil {
112+
t.Fatalf("unexpected error: %v", err)
113+
}
114+
if len(sc.ParameterMappings) != 1 {
115+
t.Fatalf("ParameterMappings: want 1, got %d", len(sc.ParameterMappings))
116+
}
117+
if sc.ParameterMappings[0].ParamName != "Asset" {
118+
t.Errorf("ParamName: want Asset, got %q", sc.ParameterMappings[0].ParamName)
119+
}
120+
if sc.ParameterMappings[0].Argument != "$Asset" {
121+
t.Errorf("Argument: want $Asset, got %q", sc.ParameterMappings[0].Argument)
122+
}
123+
}
124+
125+
// TestSnippetCall_NoParam_NoSnippetParams_Succeeds verifies that a parameterless
126+
// snippet call against a snippet with no declared parameters works (no regression).
127+
func TestSnippetCall_NoParam_NoSnippetParams_Succeeds(t *testing.T) {
128+
mod := mkModule("Mod")
129+
snp := mkSnippet(mod.ID, "Footer") // no parameters declared
130+
131+
h := mkHierarchy(mod)
132+
withContainer(h, snp.ContainerID, mod.ID)
133+
134+
mb := &mock.MockBackend{
135+
IsConnectedFunc: func() bool { return true },
136+
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
137+
ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil },
138+
ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snp}, nil },
139+
}
140+
141+
pb := newPageBuilder(mb, h, "Mod")
142+
w := &ast.WidgetV3{
143+
Type: "SNIPPETCALL",
144+
Name: "sc1",
145+
Properties: map[string]any{"Snippet": "Mod.Footer"},
146+
}
147+
148+
sc, err := pb.buildSnippetCallV3(w)
149+
if err != nil {
150+
t.Fatalf("unexpected error for parameterless snippet: %v", err)
151+
}
152+
if len(sc.ParameterMappings) != 0 {
153+
t.Errorf("expected empty ParameterMappings, got %d", len(sc.ParameterMappings))
154+
}
155+
}
156+
157+
// TestSnippetCall_DollarPrefixParam_Succeeds verifies that param names with $
158+
// prefix (as the user might write $Asset: $var) are matched correctly.
159+
func TestSnippetCall_DollarPrefixParam_Succeeds(t *testing.T) {
160+
mod := mkModule("Mod")
161+
snp := mkSnippetWithParam(mod.ID, "MySnippet", "Asset")
162+
163+
h := mkHierarchy(mod)
164+
withContainer(h, snp.ContainerID, mod.ID)
165+
166+
mb := &mock.MockBackend{
167+
IsConnectedFunc: func() bool { return true },
168+
ListModulesFunc: func() ([]*model.Module, error) { return []*model.Module{mod}, nil },
169+
ListFoldersFunc: func() ([]*types.FolderInfo, error) { return nil, nil },
170+
ListSnippetsFunc: func() ([]*pages.Snippet, error) { return []*pages.Snippet{snp}, nil },
171+
}
172+
173+
pb := newPageBuilder(mb, h, "Mod")
174+
// User writes $Asset: $Asset (dollar-prefixed param name)
175+
w := &ast.WidgetV3{
176+
Type: "SNIPPETCALL",
177+
Name: "sc1",
178+
Properties: map[string]any{
179+
"Snippet": "Mod.MySnippet",
180+
"Params": []ast.SnippetCallParam{{ParamName: "$Asset", Variable: "$Asset"}},
181+
},
182+
}
183+
184+
sc, err := pb.buildSnippetCallV3(w)
185+
if err != nil {
186+
t.Fatalf("unexpected error with $-prefixed param name: %v", err)
187+
}
188+
if len(sc.ParameterMappings) != 1 {
189+
t.Fatalf("ParameterMappings: want 1, got %d", len(sc.ParameterMappings))
190+
}
191+
if sc.ParameterMappings[0].ParamName != "Asset" {
192+
t.Errorf("ParamName: want Asset (stripped), got %q", sc.ParameterMappings[0].ParamName)
193+
}
194+
}

0 commit comments

Comments
 (0)