Skip to content

Commit baa8253

Browse files
committed
Merge branch 'issues-2'
2 parents 1c4984c + 276571e commit baa8253

8 files changed

Lines changed: 194 additions & 12 deletions

File tree

cmd/mxcli/cmd_marketplace_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ func runMarketplace(t *testing.T, handler http.HandlerFunc, args ...string) (str
5656

5757
func TestMarketplaceSearch_TableOutput(t *testing.T) {
5858
out, err := runMarketplace(t, func(w http.ResponseWriter, r *http.Request) {
59-
if !strings.Contains(r.URL.RawQuery, "search=database") {
60-
t.Errorf("expected search=database in query, got %q", r.URL.RawQuery)
59+
if !strings.Contains(r.URL.RawQuery, "search=community") {
60+
t.Errorf("expected search=community in query, got %q", r.URL.RawQuery)
6161
}
6262
_, _ = w.Write([]byte(sampleContentList))
63-
}, "search", "database")
63+
}, "search", "community")
6464

6565
if err != nil {
6666
t.Fatalf("run: %v\noutput: %s", err, out)
@@ -76,7 +76,7 @@ func TestMarketplaceSearch_TableOutput(t *testing.T) {
7676
func TestMarketplaceSearch_JSON(t *testing.T) {
7777
out, err := runMarketplace(t, func(w http.ResponseWriter, _ *http.Request) {
7878
_, _ = w.Write([]byte(sampleContentList))
79-
}, "search", "database", "--json")
79+
}, "search", "community", "--json")
8080

8181
if err != nil {
8282
t.Fatal(err)

internal/marketplace/client.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,14 @@ import (
1010
"net/http"
1111
"net/url"
1212
"strconv"
13+
"strings"
1314
)
1415

16+
// searchFetchLimit is the number of items fetched from the API when a search
17+
// query is provided. The marketplace API accepts the ?search= parameter but
18+
// does not filter server-side, so we fetch a larger page and filter locally.
19+
const searchFetchLimit = 200
20+
1521
// Client is a typed wrapper around the marketplace REST API. Callers
1622
// obtain an authenticated http.Client via internal/auth.ClientFor and
1723
// pass it here.
@@ -38,13 +44,21 @@ func NewWithBaseURL(httpClient *http.Client, baseURL string) *Client {
3844

3945
// Search lists marketplace content matching a query. limit is the
4046
// maximum number of results to return; pass 0 for the API default.
47+
//
48+
// Note: the marketplace API accepts ?search= but does not filter server-side.
49+
// When query is non-empty, this method fetches a larger page and applies
50+
// client-side filtering on the item name and publisher (case-insensitive
51+
// substring match). The user-supplied limit is applied after filtering.
4152
func (c *Client) Search(ctx context.Context, query string, limit int) (*ContentList, error) {
4253
q := url.Values{}
54+
fetchLimit := limit
4355
if query != "" {
44-
q.Set("search", query)
56+
// Fetch a larger page so client-side filtering has enough candidates.
57+
q.Set("search", query) // kept in case the API ever starts honouring it
58+
fetchLimit = searchFetchLimit
4559
}
46-
if limit > 0 {
47-
q.Set("limit", strconv.Itoa(limit))
60+
if fetchLimit > 0 {
61+
q.Set("limit", strconv.Itoa(fetchLimit))
4862
}
4963
path := "/v1/content"
5064
if len(q) > 0 {
@@ -54,9 +68,33 @@ func (c *Client) Search(ctx context.Context, query string, limit int) (*ContentL
5468
if err := c.get(ctx, path, &out); err != nil {
5569
return nil, err
5670
}
71+
72+
if query != "" {
73+
out.Items = filterItems(out.Items, query)
74+
if limit > 0 && len(out.Items) > limit {
75+
out.Items = out.Items[:limit]
76+
}
77+
}
5778
return &out, nil
5879
}
5980

81+
// filterItems returns items whose name or publisher contains query
82+
// (case-insensitive substring match).
83+
func filterItems(items []Content, query string) []Content {
84+
q := strings.ToLower(query)
85+
var matched []Content
86+
for _, item := range items {
87+
name := ""
88+
if item.LatestVersion != nil {
89+
name = strings.ToLower(item.LatestVersion.Name)
90+
}
91+
if strings.Contains(name, q) || strings.Contains(strings.ToLower(item.Publisher), q) {
92+
matched = append(matched, item)
93+
}
94+
}
95+
return matched
96+
}
97+
6098
// Get returns the full detail for a single content item by ID.
6199
func (c *Client) Get(ctx context.Context, contentID int) (*Content, error) {
62100
var out Content

internal/marketplace/client_test.go

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"net/http"
88
"net/http/httptest"
9+
"strconv"
910
"strings"
1011
"testing"
1112
"time"
@@ -36,6 +37,48 @@ const sampleContentList = `{
3637
]
3738
}`
3839

40+
// sampleMultiContentList has two items: one matching "database", one not.
41+
const sampleMultiContentList = `{
42+
"items": [
43+
{
44+
"contentId": 2888,
45+
"publisher": "Mendix",
46+
"type": "Module",
47+
"latestVersion": {
48+
"name": "Database Connector",
49+
"versionId": "aaaa",
50+
"versionNumber": "3.1.0",
51+
"minSupportedMendixVersion": "9.0.0",
52+
"publicationDate": "2025-06-01T00:00:00Z"
53+
}
54+
},
55+
{
56+
"contentId": 170,
57+
"publisher": "Mendix",
58+
"type": "Module",
59+
"latestVersion": {
60+
"name": "Community Commons",
61+
"versionId": "bbbb",
62+
"versionNumber": "11.5.0",
63+
"minSupportedMendixVersion": "10.24.0",
64+
"publicationDate": "2026-01-13T00:00:00Z"
65+
}
66+
},
67+
{
68+
"contentId": 999,
69+
"publisher": "ACME",
70+
"type": "Module",
71+
"latestVersion": {
72+
"name": "Advanced Database Tools",
73+
"versionId": "cccc",
74+
"versionNumber": "1.0.0",
75+
"minSupportedMendixVersion": "9.0.0",
76+
"publicationDate": "2024-01-01T00:00:00Z"
77+
}
78+
}
79+
]
80+
}`
81+
3982
const sampleContent = `{
4083
"contentId": 170,
4184
"publisher": "Mendix",
@@ -79,9 +122,11 @@ func TestSearch_PassesQueryAndLimit(t *testing.T) {
79122
gotPath = r.URL.Path
80123
gotQuery = r.URL.RawQuery
81124
w.Header().Set("Content-Type", "application/json")
82-
_, _ = w.Write([]byte(sampleContentList))
125+
_, _ = w.Write([]byte(sampleMultiContentList))
83126
})
84127

128+
// User requests limit=3 but because the API ignores ?search=, we fetch
129+
// searchFetchLimit items and apply client-side filtering.
85130
result, err := client.Search(context.Background(), "database", 3)
86131
if err != nil {
87132
t.Fatalf("Search: %v", err)
@@ -90,13 +135,72 @@ func TestSearch_PassesQueryAndLimit(t *testing.T) {
90135
t.Errorf("path: got %q, want /v1/content", gotPath)
91136
}
92137
if !strings.Contains(gotQuery, "search=database") {
93-
t.Errorf("query missing search: %q", gotQuery)
138+
t.Errorf("query missing search param: %q", gotQuery)
139+
}
140+
// API receives searchFetchLimit, not the user's limit=3
141+
if !strings.Contains(gotQuery, "limit="+strconv.Itoa(searchFetchLimit)) {
142+
t.Errorf("expected API to receive limit=%d, got query %q", searchFetchLimit, gotQuery)
94143
}
95-
if !strings.Contains(gotQuery, "limit=3") {
96-
t.Errorf("query missing limit: %q", gotQuery)
144+
// Client-side filter: only items whose name contains "database"
145+
if len(result.Items) != 2 {
146+
t.Errorf("expected 2 filtered items (Database Connector + Advanced Database Tools), got %d", len(result.Items))
147+
}
148+
}
149+
150+
func TestSearch_ClientSideFiltering(t *testing.T) {
151+
client, _ := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
152+
_, _ = w.Write([]byte(sampleMultiContentList))
153+
})
154+
155+
result, err := client.Search(context.Background(), "community", 20)
156+
if err != nil {
157+
t.Fatal(err)
97158
}
98159
if len(result.Items) != 1 || result.Items[0].ContentID != 170 {
99-
t.Errorf("unexpected result: %+v", result)
160+
t.Errorf("expected only Community Commons (170), got %+v", result.Items)
161+
}
162+
}
163+
164+
func TestSearch_ClientSideFiltering_NoMatch(t *testing.T) {
165+
client, _ := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
166+
_, _ = w.Write([]byte(sampleMultiContentList))
167+
})
168+
169+
result, err := client.Search(context.Background(), "zzzznonexistent", 20)
170+
if err != nil {
171+
t.Fatal(err)
172+
}
173+
if len(result.Items) != 0 {
174+
t.Errorf("expected 0 results for nonexistent query, got %d", len(result.Items))
175+
}
176+
}
177+
178+
func TestSearch_ClientSideFiltering_LimitApplied(t *testing.T) {
179+
client, _ := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
180+
_, _ = w.Write([]byte(sampleMultiContentList))
181+
})
182+
183+
// 2 items match "database", but user wants only 1
184+
result, err := client.Search(context.Background(), "database", 1)
185+
if err != nil {
186+
t.Fatal(err)
187+
}
188+
if len(result.Items) != 1 {
189+
t.Errorf("expected limit of 1 applied after filtering, got %d items", len(result.Items))
190+
}
191+
}
192+
193+
func TestSearch_PublisherFiltering(t *testing.T) {
194+
client, _ := newMockServer(t, func(w http.ResponseWriter, r *http.Request) {
195+
_, _ = w.Write([]byte(sampleMultiContentList))
196+
})
197+
198+
result, err := client.Search(context.Background(), "acme", 20)
199+
if err != nil {
200+
t.Fatal(err)
201+
}
202+
if len(result.Items) != 1 || result.Items[0].ContentID != 999 {
203+
t.Errorf("expected ACME item (999), got %+v", result.Items)
100204
}
101205
}
102206

mdl-examples/doctype-tests/empty_java_action_argument.mdl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
create module SampleModule;
2+
3+
create persistent entity SampleModule.Item (
4+
Name: string(200)
5+
);
6+
7+
create java action SampleModule.Recalculate(
8+
CompanyId: string,
9+
RecalculateAll: boolean not null,
10+
ItemList: list of SampleModule.Item
11+
) returns void;
12+
113
create microflow SampleModule.ACT_RecalculateOpenItems ()
214
returns Void
315
begin

mdl-examples/doctype-tests/enum_split_statement.mdl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ begin
2020
set $IsOpen = false;
2121
when Closed then
2222
set $IsOpen = false;
23+
when (empty) then
24+
set $IsOpen = false;
2325
end case;
2426
return $IsOpen;
2527
end;

mdl/executor/cmd_misc.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,15 @@ func execExit(ctx *ExecContext) error {
390390
return ErrExit
391391
}
392392

393+
// maxScriptDepth is the maximum allowed EXECUTE SCRIPT nesting level.
394+
const maxScriptDepth = 16
395+
393396
// execExecuteScript handles EXECUTE SCRIPT statements.
394397
func execExecuteScript(ctx *ExecContext, s *ast.ExecuteScriptStmt) error {
398+
if ctx.ScriptDepth >= maxScriptDepth {
399+
return mdlerrors.NewValidationf("maximum script nesting depth (%d) exceeded — possible recursive EXECUTE SCRIPT", maxScriptDepth)
400+
}
401+
395402
// Resolve path relative to current working directory
396403
scriptPath := s.Path
397404
if !filepath.IsAbs(scriptPath) {
@@ -426,6 +433,8 @@ func execExecuteScript(ctx *ExecContext, s *ast.ExecuteScriptStmt) error {
426433
if ctx.ExecuteFn == nil {
427434
return mdlerrors.NewBackend("execute script", errors.New("ExecuteFn not set — ExecContext was not created via Executor dispatch"))
428435
}
436+
ctx.ScriptDepth++
437+
defer func() { ctx.ScriptDepth-- }()
429438
for _, stmt := range prog.Statements {
430439
if err := ctx.ExecuteFn(stmt); err != nil {
431440
// Exit within a script just stops the script, doesn't exit mxcli

mdl/executor/cmd_misc_mock_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,15 @@ func TestHelp_Mock(t *testing.T) {
7171
assertContainsStr(t, out, "MDL Commands")
7272
assertContainsStr(t, out, "connect local")
7373
}
74+
75+
// ---------------------------------------------------------------------------
76+
// EXECUTE SCRIPT — depth limit
77+
// ---------------------------------------------------------------------------
78+
79+
func TestExecuteScript_DepthLimitExceeded(t *testing.T) {
80+
ctx, _ := newMockCtx(t)
81+
ctx.ScriptDepth = maxScriptDepth
82+
err := execExecuteScript(ctx, &ast.ExecuteScriptStmt{Path: "/some/script.mdl"})
83+
assertError(t, err)
84+
assertContainsStr(t, err.Error(), "maximum script nesting depth")
85+
}

mdl/executor/exec_context.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ type ExecContext struct {
8787
// It lets activity formatting distinguish a terminal void EndEvent from an
8888
// empty EndEvent in a value-returning microflow, where bare `return;` is invalid.
8989
DescribingMicroflowHasReturnValue bool
90+
91+
// ScriptDepth tracks the current EXECUTE SCRIPT nesting level.
92+
// Incremented on each recursive call; execExecuteScript rejects calls
93+
// that exceed maxScriptDepth to prevent infinite self-referencing scripts.
94+
ScriptDepth int
9095
}
9196

9297
// Connected returns true if a project is connected via the Backend.

0 commit comments

Comments
 (0)