Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

- **`mxcli search --fail-on-match` turns the search into a gate** — a pipeline that must refuse a change when a forbidden word turns up in the project's text had no exit status to read: `search` exited 0 whether it found something or not. With the flag it exits 1 when there is at least one hit and 0 when there is none, printing the results as before (a JSON array under `--json`). A search that cannot run, such as an unbalanced `"` or an unknown project, exits 2 so that 1 always means a match; the `SEARCH` statement still answers an unrunnable query with "No matches found.". Without the flag a hit exits 0 as before. Rules: docs-site "Full-Text Search".
- **`filemanager` — the classic file manager (`Forms$FileManager`) in MDL** (mendixlabs/mxcli#151) — `filemanager fileManager1 (Label: 'File', Type: Upload, MaxFileSize: 10, AllowedExtensions: '.xlsx', ShowFileInBrowser: false)` inside a data view on a `System.FileDocument` specialization. Before, there was no way to author one: `pluggablewidget 'Forms$FileManager'` had no definition, the bare keyword was refused as MDL-WIDGET25, and `describe page` printed a stored one as a NOT re-executable comment, so describe → exec dropped it. Omitted properties take Studio Pro's defaults (`Both`, 5 MB, any file, false); the written BSON has the property set of a Studio Pro-authored file manager. `alter page … set` changes all four properties. A file manager in no data container is MDL-WIDGET45 at `check`, and one in a data view on an entity that is not a FileDocument is refused by `exec` — both were mxbuild's CE6810 after a clean `exec`.

### Changed
Expand Down
31 changes: 28 additions & 3 deletions cmd/mxcli/cmd_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,21 +257,36 @@ Output Formats:
query command takes). "--format json" is still accepted as a
deprecated spelling of it.

Gating on a word:
--fail-on-match exit 1 when there is at least one hit, 0 when there is none
(results are printed as usual, JSON included). A query the
index cannot run, or any other error, exits 2 so that 1
always means "something matched".

Examples:
mxcli search -p app.mpr "validation"
mxcli search -p app.mpr "Customer" --json | jq '.[].qualifiedName'
mxcli search -p app.mpr "Customer" --format names
mxcli search -p app.mpr "error" -q --format names | xargs -I {} mxcli describe -p app.mpr microflow {}
mxcli search -p app.mpr "TODO" --fail-on-match
`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
projectPath, _ := cmd.Flags().GetString("project")
format := resolveFormat(cmd, "table")
quiet, _ := cmd.Flags().GetBool("quiet")
failOnMatch, _ := cmd.Flags().GetBool("fail-on-match")

// Under --fail-on-match, exit status 1 means "something matched", so a
// search that could not run says so with 2 rather than with 1.
errorExit := 1
if failOnMatch {
errorExit = 2
}

if projectPath == "" {
fmt.Fprintln(os.Stderr, "Error: --project (-p) is required")
os.Exit(1)
os.Exit(errorExit)
}

// A JSON-format executor sends "Connected to:" and catalog progress to
Expand All @@ -293,14 +308,24 @@ Examples:
for _, stmt := range connectProg.Statements {
if err := exec.Execute(stmt); err != nil {
fmt.Fprintf(os.Stderr, "Error connecting: %v\n", err)
os.Exit(1)
os.Exit(errorExit)
}
}

// Execute search with format option
query := strings.ReplaceAll(args[0], "'", "''")
if err := exec.Search(query, format); err != nil {
hits, err := exec.SearchWith(query, format, executor.SearchOptions{
// A gate must not read "the index refused the query" as "no match".
Strict: failOnMatch,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(errorExit)
}
if failOnMatch && hits > 0 {
// os.Exit skips the deferred closes, and the log file would stay open.
exec.Close()
logger.Close()
os.Exit(1)
}
},
Expand Down
78 changes: 78 additions & 0 deletions cmd/mxcli/cmd_search_fail_on_match_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: Apache-2.0

package main

import (
"encoding/json"
"path/filepath"
"testing"
)

// `search --fail-on-match` makes the command a gate: a pipeline can refuse a
// change when a forbidden word turns up anywhere in the project's text. The exit
// status carries the answer (1 = something matched, 0 = nothing did), while the
// output stays what it always was, including a pure JSON array under --json.
//
// These run the real main() in a child process (see runMainStreams), so the exit
// code is the one a shell would see.
func TestSearchFailOnMatch_ExitCode(t *testing.T) {
if testing.Short() {
t.Skip("spawns mxcli against a project")
}
mpr := jsonPurityFixture(t)
dir := filepath.Dir(mpr)

cases := []struct {
name string
query string
wantCode int
wantHits bool
}{
{"a hit exits 1", "User", 1, true},
{"no hit exits 0", "zzqqxxnomatch", 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
args := []string{"-p", mpr, "--json", "search", tc.query, "--fail-on-match"}
r := runMainStreams(t, dir, args...)
if r.exitCode != tc.wantCode {
t.Fatalf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s", r.exitCode, tc.wantCode, r.stdout, r.stderr)
}
assertPureJSON(t, args, r)
var rows []map[string]any
if err := json.Unmarshal([]byte(r.stdout), &rows); err != nil {
t.Fatalf("stdout is not a JSON array: %v\n%s", err, r.stdout)
}
if (len(rows) > 0) != tc.wantHits {
t.Errorf("rows = %d, want hits = %v\n%s", len(rows), tc.wantHits, r.stdout)
}
})
}
}

// Without the flag a hit is still exit 0: the flag is opt-in, and every script
// that already calls `search` keeps working.
func TestSearchWithoutFailOnMatch_HitExitsZero(t *testing.T) {
if testing.Short() {
t.Skip("spawns mxcli against a project")
}
mpr := jsonPurityFixture(t)
r := runMainStreams(t, filepath.Dir(mpr), "-p", mpr, "--json", "search", "User")
if r.exitCode != 0 {
t.Fatalf("exit code = %d, want 0\nstderr:\n%s", r.exitCode, r.stderr)
}
}

// A query the index cannot run is neither a hit nor a clean bill. Under the gate
// it exits 2, so 1 keeps meaning "something matched" and a failing search is
// never mistaken for a passing one.
func TestSearchFailOnMatch_UnrunnableQueryExitsTwo(t *testing.T) {
if testing.Short() {
t.Skip("spawns mxcli against a project")
}
mpr := jsonPurityFixture(t)
r := runMainStreams(t, filepath.Dir(mpr), "-p", mpr, "--json", "search", `a"b`, "--fail-on-match")
if r.exitCode != 2 {
t.Fatalf("exit code = %d, want 2\nstdout:\n%s\nstderr:\n%s", r.exitCode, r.stdout, r.stderr)
}
}
1 change: 1 addition & 0 deletions cmd/mxcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ func init() {
// Search command flags
searchCmd.Flags().StringP("format", "f", "table", "Output format: table, names (for JSON use --json; \"json\" is accepted as a deprecated alias)")
searchCmd.Flags().BoolP("quiet", "q", false, "Suppress connection and status messages (for piping)")
searchCmd.Flags().Bool("fail-on-match", false, "Exit 1 when there is at least one hit, 0 when there is none (an error exits 2)")

// Callers/callees command flags
callersCmd.Flags().BoolP("transitive", "t", false, "Find transitive (indirect) callers")
Expand Down
5 changes: 4 additions & 1 deletion cmd/mxcli/syntax/features_misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,7 @@ mxcli -p app.mpr --json -c "LIST FOLDERS IN MyModule"
Syntax: `SEARCH '<query>';

-- CLI
mxcli search -p app.mpr "<query>" [--format table|names|json] [-q]
mxcli search -p app.mpr "<query>" [--format table|names|json] [-q] [--fail-on-match]

-- Raw FTS queries
SELECT * FROM CATALOG.STRINGS WHERE strings MATCH '<query>';
Expand All @@ -1183,6 +1183,9 @@ SEARCH 'Customer';
-- CLI with piping
mxcli search -p app.mpr "validation" -q --format names

-- Gate: exit 1 on a hit, 0 on none (2 when the search cannot run)
mxcli search -p app.mpr "TODO" --fail-on-match

-- FTS5 operators
SEARCH 'word1 OR word2';
SEARCH '"exact phrase"';
Expand Down
20 changes: 20 additions & 0 deletions docs-site/src/tools/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ mxcli search -p app.mpr "validation" -q --format names

# Output as JSON array
mxcli search -p app.mpr "validation" --json

# Exit 1 when anything matches (0 when nothing does)
mxcli search -p app.mpr "validation" --fail-on-match
```

## Output Formats
Expand Down Expand Up @@ -102,6 +105,23 @@ mxcli search -p app.mpr "error" -q --format names | head -1 | awk '{print $2}' |

**MDL source** (only after `refresh catalog full source`): microflow, nanoflow and page bodies, expressions and the other definitions as MDL text. Until it is built, a warning on stderr says only strings were searched.

## Failing on a match

`--fail-on-match` turns `search` into a gate. The results are printed as usual (a JSON array under `--json`), and the exit status carries the answer:

| Exit status | Meaning |
|-------------|---------|
| `0` | No hit |
| `1` | At least one hit |
| `2` | The search could not run: an unknown project, or a query the index rejects (an unbalanced `"`, say) |

```bash
# Refuse the change if a placeholder is left anywhere in the project's text
mxcli search -p app.mpr "TODO" --fail-on-match --json
```

Without the flag a hit exits `0`, as before.

## Comparison with Catalog Queries

`SEARCH` provides quick keyword-based search, while catalog queries (`SELECT FROM CATALOG.*`) provide structured SQL-based queries with filtering, joining, and aggregation. Use `SEARCH` for quick discovery and catalog queries for precise analysis.
Expand Down
Loading
Loading