From b9eb3e3e2fecea7de71fa146bd5db42acb0c3d57 Mon Sep 17 00:00:00 2001 From: "T. von Dein" Date: Mon, 8 Jun 2026 14:00:22 +0200 Subject: [PATCH] add search validate+explain (#31) --- TODO.md | 27 -------------- cmd/doc.go | 8 ----- cmd/root.go | 46 ++++++++++++++++++++---- cmd/search.go | 48 +++++++------------------ pkg/cfg/config.go | 1 + pkg/es/search.go | 89 ++++++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 138 insertions(+), 81 deletions(-) diff --git a/TODO.md b/TODO.md index 79564a2..87ad16a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,40 +1,13 @@ - [Go client docs](https://www.elastic.co/docs/reference/elasticsearch/clients/go/typed-api) - [ES API docs](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get) -- Fix index names custom completion - https://github.com/urfave/cli/issues/2332 - https://github.com/urfave/cli/issues/2333 - - index show: add more details, see screenshots - add shard explain, aka: get /_cluster/allocation/explain {"index":"yourindex", "primary": true, "shard":0} -- add validate: - -> GET /mock/_validate/query?rewrite=true {"from":0,"query":{"bool":{"must":[{"match_all":{}}]}},"size":20,"sort":[{"name":{"order":"desc"}}]} -{ - "valid": false -} - -- add explain to search (maybe option -e) - -> GET /mock/_explain/1780043878 {"query":{"bool":{"must":[{"match_all":{}}]}}} -{ - "_index": "mock", - "_id": "1780043878", - "matched": true, - "explanation": { - "value": 1.0, - "description": "*:*", - "details": [] - } -} - - add datastream support: https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-indices-get-data-stream also exclude data stream backing indices from index ls - -- add filter option to index ls (regex), maybe to other ls commands as well, use -F diff --git a/cmd/doc.go b/cmd/doc.go index 91b559e..0cb8d56 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -92,10 +92,6 @@ func DocShow(conf *cfg.Config) *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { - if conf.Subhelp { - return showJsonPathHelp() - } - args := cmd.Args() if args.Len() != 1 { @@ -155,10 +151,6 @@ func DocDelete(conf *cfg.Config) *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { - if conf.Subhelp { - return showJsonPathHelp() - } - args := cmd.Args() if args.Len() == 0 && !conf.All { diff --git a/cmd/root.go b/cmd/root.go index 8882721..3dc28d0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,12 +83,6 @@ func Main() int { Usage: "output mode (tsv, markdown, json, yaml) default: tsv", Destination: &conf.Output, }, - &cli.BoolFlag{ - Name: "help-jsonpath", - Usage: "show jsonPath help", - Destination: &conf.Subhelp, - Aliases: []string{"H"}, - }, }, Commands: []*cli.Command{ @@ -104,6 +98,7 @@ func Main() int { Version(conf), Debug(conf), Roles(conf), + HelpJsonPath(conf), }, Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { @@ -125,6 +120,45 @@ func Main() int { return Finish(cmd.Run(context.Background(), os.Args)) } +func HelpJsonPath(conf *cfg.Config) *cli.Command { + msg := `jsonPath usage: + +name.last >> "Anderson" +age >> 37 +children >> ["Sara","Alex","Jack"] +children.# >> 3 +children.1 >> "Alex" +child*.2 >> "Jack" +c?ildren.0 >> "Sara" +fav\.movie >> "Deer Hunter" +friends.#.first >> ["Dale","Roger","Jane"] +friends.1.last >> "Craig" + +You can also query an array for the first match by using #(...), or +find all matches with #(...)#. Queries support the ==, !=, <, <=, >, +>= comparison operators and the simple pattern matching % (like) and +!% (not like) operators. Eg: + +friends.#(last=="Murphy").first >> "Dale" +friends.#(last=="Murphy")#.first >> ["Dale","Jane"] +friends.#(age>45)#.last >> ["Craig","Murphy"] +friends.#(first%"D*").last >> "Murphy" +friends.#(first!%"D*").last >> "Craig" +friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"] + +Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md` + + return &cli.Command{ + Name: "help-jsonpath", + Usage: "show jsonpath help", + + Action: func(ctx context.Context, cmd *cli.Command) error { + _, err := fmt.Println(msg) + return err + }, + } +} + func Version(conf *cfg.Config) *cli.Command { return &cli.Command{ Name: "version", diff --git a/cmd/search.go b/cmd/search.go index 3d68506..2ebfc94 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -18,7 +18,6 @@ package cmd import ( "context" - "fmt" "codeberg.org/scip/esctl/pkg/cfg" "codeberg.org/scip/esctl/pkg/es" @@ -125,13 +124,21 @@ func Search(conf *cfg.Config) *cli.Command { Destination: &conf.Or, Aliases: []string{"O"}, }, + &cli.BoolFlag{ + Name: "validate", + Usage: "validate search query", + Destination: &conf.Validate, + Aliases: []string{"v"}, + }, + &cli.BoolFlag{ + Name: "explain", + Usage: "explain search query", + Destination: &conf.Explain, + Aliases: []string{"e"}, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - if conf.Subhelp { - return showJsonPathHelp() - } - args := cmd.Args() if conf.To == -1 { @@ -142,34 +149,3 @@ func Search(conf *cfg.Config) *cli.Command { }, } } - -func showJsonPathHelp() error { - _, err := fmt.Println(`jsonPath usage: - -name.last >> "Anderson" -age >> 37 -children >> ["Sara","Alex","Jack"] -children.# >> 3 -children.1 >> "Alex" -child*.2 >> "Jack" -c?ildren.0 >> "Sara" -fav\.movie >> "Deer Hunter" -friends.#.first >> ["Dale","Roger","Jane"] -friends.1.last >> "Craig" - -You can also query an array for the first match by using #(...), or -find all matches with #(...)#. Queries support the ==, !=, <, <=, >, ->= comparison operators and the simple pattern matching % (like) and -!% (not like) operators. Eg: - -friends.#(last=="Murphy").first >> "Dale" -friends.#(last=="Murphy")#.first >> ["Dale","Jane"] -friends.#(age>45)#.last >> ["Craig","Murphy"] -friends.#(first%"D*").last >> "Murphy" -friends.#(first!%"D*").last >> "Craig" -friends.#(nets.#(=="fb"))#.first >> ["Dale","Roger"] - -Documentation: https://github.com/tidwall/gjson/blob/master/SYNTAX.md`) - - return err -} diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 381029e..a4905e8 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -70,6 +70,7 @@ type Config struct { Range string // search: -r TimestampFormat string // search: --timestamp-format Explain bool // search: -e + Validate bool // search: --validate SortBy string // sort: -k Ascending bool // sort: -a Exclude string // cluster compare: -e (regexp) diff --git a/pkg/es/search.go b/pkg/es/search.go index 8447af2..494f721 100644 --- a/pkg/es/search.go +++ b/pkg/es/search.go @@ -18,6 +18,7 @@ package es import ( "context" + "encoding/json" "fmt" "log" "log/slog" @@ -28,8 +29,10 @@ import ( "github.com/alecthomas/repr" "github.com/elastic/go-elasticsearch/v9/typedapi/core/search" "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" + "github.com/elastic/go-elasticsearch/v9/typedapi/indices/validatequery" "github.com/elastic/go-elasticsearch/v9/typedapi/types" "github.com/elastic/go-elasticsearch/v9/typedapi/types/enums/sortorder" + "github.com/tidwall/gjson" ) const ( @@ -43,8 +46,11 @@ Execute an ES search. additional filters can be given as -F key=value */ func Search(conf *cfg.Config, queries []string) error { - searchEs := conf.DefaultCluster.ES.Search(). - Index(conf.Index) + if conf.Validate { + return validateSearch(conf, queries) + } + + searchEs := conf.DefaultCluster.ES.Search().Index(conf.Index) queryCaster, err := prepareQuery(conf, queries) if err != nil { @@ -57,9 +63,11 @@ func Search(conf *cfg.Config, queries []string) error { searchEs = addSort(conf, searchEs) - switch conf.Tail { - case true: + switch { + case conf.Tail: return searchTail(conf, searchEs) + case conf.Explain: + return explainSearch(conf, searchEs) default: if conf.To > MAXPAGE { return searchPit(conf, req) @@ -69,6 +77,79 @@ func Search(conf *cfg.Config, queries []string) error { } } +func explainSearch(conf *cfg.Config, search *search.Search) error { + res, err := search. + Explain(true). + Size(1). // one's enough for explain + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to call explain search (esdsl): %s", esErrorString(err)) + } + + raw, err := json.Marshal(res) + if err != nil { + return fmt.Errorf("failed to marshal explain result: %s", err) + } + + if conf.Debug { + repr.Println(res) + value := gjson.Get(string(raw), "hits.hits.0._explanation") + fmt.Println(value.String()) + } + + if len(res.Hits.Hits) > 0 { + ex := res.Hits.Hits[0].Explanation_ + fmt.Println(ex.Description) + fmt.Println(ex.Value) + + // recurse into explanation details (it's a tree) + for _, ex := range ex.Details { + explain(&ex, " ") + } + } + + return nil +} + +func explain(res *types.ExplanationDetail, indent string) { + fmt.Println(indent + "- " + res.Description) + for _, ex := range res.Details { + fmt.Println(indent + " - " + ex.Description) + fmt.Println(indent + fmt.Sprintf(" score: %f", res.Value)) + + explain(&ex, indent+" ") + } +} + +func validateSearch(conf *cfg.Config, queries []string) error { + validate := conf.DefaultCluster.ES.Indices.ValidateQuery() + + queryCaster, err := prepareQuery(conf, queries) + if err != nil { + return err + } + + req := &validatequery.Request{Query: queryCaster} + + validate.Request(req) + + res, err := validate. + Do(context.Background()) + if err != nil { + return fmt.Errorf("failed to validate search (esdsl): %s", esErrorString(err)) + } + + slog.Debug("ES result", "search", res) + + if res.Valid { + fmt.Println(printer.Colorize(conf, "green", "valid")) + } else { + fmt.Println(printer.Colorize(conf, "red", "invalid")) + } + + return nil +} + func Debug(conf *cfg.Config) error { res, err := conf.DefaultCluster.ES.Search(). Index(conf.Index).