From 328268d07d30c2bc16c39117fb0936b3f3e81985 Mon Sep 17 00:00:00 2001 From: stagknee <241821830+stagknee@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:46:45 -0500 Subject: [PATCH] feat: mxcli search --fail-on-match exits 1 on a hit, 0 on none (#1477) A pipeline that must refuse a change when a forbidden word turns up in the project's text had no exit status to read. With --fail-on-match, search exits 1 on at least one hit and 0 on none; a search that cannot run (an unbalanced quote, an unknown project) exits 2, so 1 always means a match. The SEARCH statement and search without the flag behave as before. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + cmd/mxcli/cmd_query.go | 31 ++++- cmd/mxcli/cmd_search_fail_on_match_test.go | 78 +++++++++++ cmd/mxcli/main.go | 1 + cmd/mxcli/syntax/features_misc.go | 5 +- docs-site/src/tools/search.md | 20 +++ mdl/executor/cmd_catalog.go | 130 ++++++++++++++----- mdl/executor/cmd_search_options_test.go | 69 ++++++++++ mdl/executor/cmd_search_source_index_test.go | 10 ++ 9 files changed, 306 insertions(+), 39 deletions(-) create mode 100644 cmd/mxcli/cmd_search_fail_on_match_test.go create mode 100644 mdl/executor/cmd_search_options_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2365f748c2..19ceafaae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/mxcli/cmd_query.go b/cmd/mxcli/cmd_query.go index 671e9abb7e..140469341d 100644 --- a/cmd/mxcli/cmd_query.go +++ b/cmd/mxcli/cmd_query.go @@ -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 @@ -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) } }, diff --git a/cmd/mxcli/cmd_search_fail_on_match_test.go b/cmd/mxcli/cmd_search_fail_on_match_test.go new file mode 100644 index 0000000000..9cbe4d7d64 --- /dev/null +++ b/cmd/mxcli/cmd_search_fail_on_match_test.go @@ -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) + } +} diff --git a/cmd/mxcli/main.go b/cmd/mxcli/main.go index a68cb28872..2769527258 100644 --- a/cmd/mxcli/main.go +++ b/cmd/mxcli/main.go @@ -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") diff --git a/cmd/mxcli/syntax/features_misc.go b/cmd/mxcli/syntax/features_misc.go index 781d4eb287..2a6b720085 100644 --- a/cmd/mxcli/syntax/features_misc.go +++ b/cmd/mxcli/syntax/features_misc.go @@ -1172,7 +1172,7 @@ mxcli -p app.mpr --json -c "LIST FOLDERS IN MyModule" Syntax: `SEARCH ''; -- CLI -mxcli search -p app.mpr "" [--format table|names|json] [-q] +mxcli search -p app.mpr "" [--format table|names|json] [-q] [--fail-on-match] -- Raw FTS queries SELECT * FROM CATALOG.STRINGS WHERE strings MATCH ''; @@ -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"'; diff --git a/docs-site/src/tools/search.md b/docs-site/src/tools/search.md index 47b4efb05b..03fc035ff3 100644 --- a/docs-site/src/tools/search.md +++ b/docs-site/src/tools/search.md @@ -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 @@ -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. diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index 40f7b8c125..e00e7b9ab2 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -1091,50 +1091,93 @@ func preWarmCache(ctx *ExecContext) { describeDefaultLanguage(ctx) } +// SearchOptions harden a search. The zero value is what the SEARCH statement +// uses. +type SearchOptions struct { + // Strict returns the index's error for a query it cannot run (an FTS5 syntax + // error, say) instead of reporting "no matches". A caller that gates on the + // absence of hits needs the difference: "nothing found" and "could not look" + // are not the same answer. + Strict bool +} + +// searchSQL returns the strings-index and source-index queries for a search. +func searchSQL(query string) (stringsSQL, sourceSQL string) { + q := escapeFTSQuery(query) + stringsSQL = fmt.Sprintf( + "select QualifiedName, ObjectType, snippet(strings, 2, '>>>', '<<<', '...', 32) as Match, StringContext, ModuleName from strings where strings match '%s' limit 50", + q) + sourceSQL = fmt.Sprintf( + "select QualifiedName, ObjectType, snippet(source, 2, '>>>', '<<<', '...', 48) as Match, ModuleName from source where source match '%s' limit 50", + q) + return stringsSQL, sourceSQL +} + +// runSearchQuery runs one of the two search queries. An error is swallowed and +// reported as no rows unless opts.Strict is set — the long-standing behaviour +// of SEARCH, kept for it. +func runSearchQuery(ctx *ExecContext, sql string, opts SearchOptions) (*catalog.QueryResult, error) { + res, err := ctx.Catalog.Query(sql) + if err != nil { + if opts.Strict { + return nil, err + } + return nil, nil + } + return res, nil +} + // execSearch handles SEARCH 'query' command. func execSearch(ctx *ExecContext, stmt *ast.SearchStmt) error { + _, err := searchTable(ctx, stmt.Query, SearchOptions{}) + return err +} + +// searchTable prints the human-readable result of a search and returns how many +// hits it printed. +func searchTable(ctx *ExecContext, query string, opts SearchOptions) (int, error) { if !ctx.Connected() { - return mdlerrors.NewNotConnected() + return 0, mdlerrors.NewNotConnected() } // Ensure catalog is built (at least full mode for strings table) if err := ensureCatalog(ctx, true); err != nil { - return err + return 0, err } - query := stmt.Query - found := false + stringsSQL, sourceSQL := searchSQL(query) + hits := 0 // Search strings table - stringsQuery := fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(strings, 2, '>>>', '<<<', '...', 32) as Match, StringContext, ModuleName from strings where strings match '%s' limit 50", - escapeFTSQuery(query)) - strResult, err := ctx.Catalog.Query(stringsQuery) - if err == nil && strResult.Count > 0 { - found = true + strResult, err := runSearchQuery(ctx, stringsSQL, opts) + if err != nil { + return 0, err + } + if strResult != nil && strResult.Count > 0 { + hits += strResult.Count fmt.Fprintf(ctx.Output, "\nString Matches (%d)\n", strResult.Count) fmt.Fprintln(ctx.Output, strings.Repeat("─", 40)) outputCatalogResults(ctx, strResult) } // Search source table (if available) - sourceQuery := fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(source, 2, '>>>', '<<<', '...', 48) as Match, ModuleName from source where source match '%s' limit 50", - escapeFTSQuery(query)) - srcResult, err := ctx.Catalog.Query(sourceQuery) - if err == nil && srcResult.Count > 0 { - found = true + srcResult, err := runSearchQuery(ctx, sourceSQL, opts) + if err != nil { + return 0, err + } + if srcResult != nil && srcResult.Count > 0 { + hits += srcResult.Count fmt.Fprintf(ctx.Output, "\nSource Matches (%d)\n", srcResult.Count) fmt.Fprintln(ctx.Output, strings.Repeat("─", 40)) outputCatalogResults(ctx, srcResult) } - if !found { + if hits == 0 { fmt.Fprintln(ctx.Output, "No matches found.") } warnIfSourceIndexMissing(ctx) - return nil + return hits, nil } // warnIfSourceIndexMissing says, on the diagnostics channel, that a search did @@ -1186,17 +1229,24 @@ func escapeFTSQuery(q string) string { // search performs a full-text search with the specified output format. // Format can be: "table" (default), "names" (just qualified names), "json" func search(ctx *ExecContext, query, format string) error { + _, err := searchWith(ctx, query, format, SearchOptions{}) + return err +} + +// searchWith is search with options. It returns how many hits it found, which +// is the number a caller gating on "no match" needs. +func searchWith(ctx *ExecContext, query, format string, opts SearchOptions) (int, error) { if !ctx.Connected() { - return mdlerrors.NewNotConnected() + return 0, mdlerrors.NewNotConnected() } if format != "names" && format != "json" { // "table" (default) - return execSearch(ctx, &ast.SearchStmt{Query: query}) + return searchTable(ctx, query, opts) } // Ensure catalog is built (at least full mode for strings table) if err := ensureCatalog(ctx, true); err != nil { - return err + return 0, err } type searchResult struct { @@ -1211,12 +1261,14 @@ func search(ctx *ExecContext, query, format string) error { var allResults []searchResult seen := make(map[string]bool) // For deduplicating names format + stringsSQL, sourceSQL := searchSQL(query) + // Search strings table - stringsQuery := fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(strings, 2, '>>>', '<<<', '...', 32) as Match, StringContext, ModuleName from strings where strings match '%s' limit 50", - escapeFTSQuery(query)) - strResult, err := ctx.Catalog.Query(stringsQuery) - if err == nil && strResult.Count > 0 { + strResult, err := runSearchQuery(ctx, stringsSQL, opts) + if err != nil { + return 0, err + } + if strResult != nil && strResult.Count > 0 { for _, row := range strResult.Rows { r := searchResult{ QualifiedName: fmt.Sprintf("%v", row[0]), @@ -1231,11 +1283,11 @@ func search(ctx *ExecContext, query, format string) error { } // Search source table (if available) - sourceQuery := fmt.Sprintf( - "select QualifiedName, ObjectType, snippet(source, 2, '>>>', '<<<', '...', 48) as Match, ModuleName from source where source match '%s' limit 50", - escapeFTSQuery(query)) - srcResult, err := ctx.Catalog.Query(sourceQuery) - if err == nil && srcResult.Count > 0 { + srcResult, err := runSearchQuery(ctx, sourceSQL, opts) + if err != nil { + return 0, err + } + if srcResult != nil && srcResult.Count > 0 { for _, row := range srcResult.Rows { r := searchResult{ QualifiedName: fmt.Sprintf("%v", row[0]), @@ -1255,7 +1307,7 @@ func search(ctx *ExecContext, query, format string) error { fmt.Fprintln(ctx.Output, "[]") } warnIfSourceIndexMissing(ctx) - return nil + return 0, nil } switch format { @@ -1271,18 +1323,26 @@ func search(ctx *ExecContext, query, format string) error { case "json": jsonBytes, err := json.MarshalIndent(allResults, "", " ") if err != nil { - return err + return 0, err } fmt.Fprintln(ctx.Output, string(jsonBytes)) } warnIfSourceIndexMissing(ctx) - return nil + return len(allResults), nil } // Search performs a full-text search with the specified output format. func (e *Executor) Search(query, format string) error { - return search(e.newExecContext(context.Background()), query, format) + _, err := e.SearchWith(query, format, SearchOptions{}) + return err +} + +// SearchWith performs a full-text search and returns the number of hits it +// found (at most 50 per index), so a caller can act on "found something" without +// parsing the output it just printed. +func (e *Executor) SearchWith(query, format string, opts SearchOptions) (int, error) { + return searchWith(e.newExecContext(context.Background()), query, format, opts) } // builtinWidgetMetas adapts the built-in WidgetRegistry's definitions into diff --git a/mdl/executor/cmd_search_options_test.go b/mdl/executor/cmd_search_options_test.go new file mode 100644 index 0000000000..30dea096f8 --- /dev/null +++ b/mdl/executor/cmd_search_options_test.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/catalog" +) + +// seedTwoHits gives the fixture catalog two more rows that contain the same +// word, so a search for it has a known number of hits. +func seedTwoHits(t *testing.T) func(catalog.CatalogDB) { + return func(db catalog.CatalogDB) { + t.Helper() + for _, q := range []string{ + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Sales.Limit', 'CONSTANT', 'Upper bound for the quuxwidget', 'caption', 'Sales')`, + `INSERT INTO strings (QualifiedName, ObjectType, StringValue, StringContext, ModuleName) + VALUES ('Sales.Other', 'CONSTANT', 'Lower bound for the quuxwidget', 'caption', 'Sales')`, + } { + if _, err := db.Exec(q); err != nil { + t.Fatalf("seed: %v\n%s", err, q) + } + } + } +} + +// A caller that gates on "no match" needs the number of hits, not the output it +// just printed. It is the same count in every format. +// +// One fixture per search: a second search on the same context made the +// TempDir cleanup fail on Windows (catalog.db still in use), so each case +// builds its own. +func TestSearchWith_ReturnsTheNumberOfHits(t *testing.T) { + for _, format := range []string{"table", "names", "json"} { + for query, want := range map[string]int{"quuxwidget": 2, "zzqqxxnomatch": 0} { + t.Run(format+"/"+query, func(t *testing.T) { + ctx, out, _ := searchCacheFixtureSeeded(t, "source", nil, seedTwoHits(t)) + n, err := searchWith(ctx, query, format, SearchOptions{}) + if err != nil { + t.Fatal(err) + } + if n != want { + t.Errorf("hits = %d, want %d\n%s", n, want, out.String()) + } + }) + } + } +} + +// A query the index cannot run is not "nothing found". The SEARCH statement has +// always answered it with "No matches found."; a gate on the absence of hits +// asks for the error instead. +func TestSearchWith_StrictReportsAQueryTheIndexRejects(t *testing.T) { + const bad = `quux"widget` + + for _, format := range []string{"table", "json"} { + ctx, _, _ := searchCacheFixtureSeeded(t, "source", nil, seedTwoHits(t)) + if n, err := searchWith(ctx, bad, format, SearchOptions{}); err != nil || n != 0 { + t.Fatalf("%s default: hits = %d, err = %v; want 0, nil (unchanged behaviour)", format, n, err) + } + + ctx, _, _ = searchCacheFixtureSeeded(t, "source", nil, seedTwoHits(t)) + if _, err := searchWith(ctx, bad, format, SearchOptions{Strict: true}); err == nil { + t.Fatalf("%s strict: want the index's error for a query it cannot run, got nil", format) + } + } +} diff --git a/mdl/executor/cmd_search_source_index_test.go b/mdl/executor/cmd_search_source_index_test.go index ec171ca674..936e843230 100644 --- a/mdl/executor/cmd_search_source_index_test.go +++ b/mdl/executor/cmd_search_source_index_test.go @@ -19,6 +19,13 @@ import ( // building. sourceRows seeds CATALOG.SOURCE; the strings table always holds // one caption containing "Invoice". func searchCacheFixture(t *testing.T, mode string, sourceRows [][2]string) (*ExecContext, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + return searchCacheFixtureSeeded(t, mode, sourceRows, nil) +} + +// searchCacheFixtureSeeded is searchCacheFixture plus a hook that writes more +// rows into the catalog before it is saved. +func searchCacheFixtureSeeded(t *testing.T, mode string, sourceRows [][2]string, seed func(catalog.CatalogDB)) (*ExecContext, *bytes.Buffer, *bytes.Buffer) { t.Helper() dir := t.TempDir() mprPath := filepath.Join(dir, "app.mpr") @@ -39,6 +46,9 @@ func searchCacheFixture(t *testing.T, mode string, sourceRows [][2]string) (*Exe VALUES ('Sales.Invoice_Overview', 'PAGE', 'Invoice overview', 'caption', 'Sales')`); err != nil { t.Fatalf("seed strings: %v", err) } + if seed != nil { + seed(db) + } for _, r := range sourceRows { if _, err := db.Exec(`INSERT INTO source (QualifiedName, ObjectType, SourceText, ModuleName, ElementId) VALUES (?, 'MICROFLOW', ?, 'Sales', 'id-1')`, r[0], r[1]); err != nil {