diff --git a/README.md b/README.md index df1bf86..667ce3b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ - [Installation](https://github.com/Llywelwyn/pda#installation) - [Get Started](https://github.com/Llywelwyn/pda#get-started) - [Templates](https://github.com/Llywelwyn/pda#templates) +- [Globs](https://github.com/Llywelwyn/pda#globs) - [Secrets](https://github.com/Llywelwyn/pda#secrets) - [TTL](https://github.com/Llywelwyn/pda#ttl) - [Binary](https://github.com/Llywelwyn/pda#binary) @@ -129,15 +130,22 @@ pda del kitty # remove "kitty": are you sure? [y/n] # y +# Or skip the prompt. +pda del kitty --force + +# Remove multiple keys, within the same or different stores. pda del kitty dog@animals # remove "kitty", "dog@animals": are you sure? [y/n] # y -# Or skip the prompt. -pda del kitty --force - -# Delete many in one go. -pda del kitty dogs cats --force +# Mix exact keys with globs. +pda set cog "cogs" +pda set dog "doggy" +pda set kitty "cat" +pda del kitty --glob ?og +# remove "kitty", "cog", "dog": are you sure? [y/n] +# y +# Default glob separators: "/-_.@:". Override with --glob-sep. ```

@@ -300,6 +308,134 @@ pda get hello --no-template

+### Globs + +Globs can be used in a few commands where their use makes sense. `gobwas/glob` is used for matching. + +Searching for globs is inherently slower than looking for direct matches, so globs are opt-in via a repeatable `--glob/-g` flag by default rather than having every string treated as a glob by default. Realistically the performance impact will be negligible unless you have many thousands of entries in the same database. + +

+ +`*` wildcards a word or series of characters. +```bash +pda ls --no-values +# cat +# dog +# cog +# mouse hotdog +# mouse house +# foo.bar.baz + +pda ls --glob "*" +# cat +# dog +# cog + +pda ls --glob "* *" +# mouse hotdog +# mouse house + +pda ls --glob "foo.*.baz" +# foo.bar.baz +``` + +

+ +`**` super-wildcards ignore word boundaries. +```bash +pda ls --glob "foo**" +# foo.bar.baz + +pda ls --glob "**g" +# dog +# cog +# mouse hotdog +``` + +

+ +`?` wildcards a single letter. +```bash +pda ls --glob ?og +# dog +# cog +# frog --> fail +# dogs --> fail +``` + +

+ +`[abc]` must match one of the characters in the brackets. +```bash +pda ls --glob [dc]og +# dog +# cog +# bog --> fail + +# Can be negated with '!' +pda ls --glob [!dc]og +# dog --> fail +# cog --> fail +# bog +``` + +

+ +`[a-c]` must fall within the range given in the brackets +```bash +pda ls --glob [a-g]ag +# bag +# gag +# wag --> fail + +# Can be negated with '!' +pda ls --glob [!a-g]ag +# bag --> fail +# gag --> fail +# wag + +pda ls --glob 19[90-99] +# 1991 +# 1992 +# 2001 --> fail +# 1988 --> fail +``` + +

+ +Globs can be arbitrarily complex, and can be combined with strict matches. +```bash +pda ls --no-keys +# cat +# mouse trap +# dog house +# cat flap +# cogwheel + +pda rm cat --glob "{mouse,[cd]og}**" +# remove: 'cat', 'mouse trap', 'dog house', 'cogwheel': are you sure? [y/n] +``` + +

+ +`--glob-sep` can be used to change the default list of separators used to determine word boundaries. Separators default to a somewhat reasonable list of common alphanumeric characters so should be usable in most usual situations. +```bash +pda ls --no-keys +# foo%baz + +pda ls --glob "*" +# foo%baz + +pda ls --glob "*" --glob-sep "%" +# foo%baz --> fail +# % is considered a word boundary, so "*" no longer matches. + +pda ls --glob "*%*" --glob-sep "%" +# foo%baz +``` + +

+ ### Secrets Mark sensitive values with `secret` to stop accidents. diff --git a/cmd/del.go b/cmd/del.go index 13f8508..da48261 100644 --- a/cmd/del.go +++ b/cmd/del.go @@ -36,7 +36,7 @@ var delCmd = &cobra.Command{ Use: "del KEY[@DB] [KEY[@DB] ...]", Short: "Delete one or more keys. Optionally specify a db.", Aliases: []string{"delete", "rm", "remove"}, - Args: cobra.MinimumNArgs(1), + Args: cobra.ArbitraryArgs, RunE: del, SilenceUsage: true, } @@ -48,12 +48,20 @@ func del(cmd *cobra.Command, args []string) error { if err != nil { return err } - useGlob, err := cmd.Flags().GetBool("glob") + globPatterns, err := cmd.Flags().GetStringSlice("glob") + if err != nil { + return err + } + separators, err := parseGlobSeparators(cmd) if err != nil { return err } - targetKeys, deleteTargets, err := resolveDeleteTargets(store, args, useGlob) + if len(args) == 0 && len(globPatterns) == 0 { + return fmt.Errorf("cannot remove: no keys provided") + } + + targetKeys, deleteTargets, err := resolveDeleteTargets(store, args, globPatterns, separators) if err != nil { return err } @@ -100,7 +108,8 @@ func del(cmd *cobra.Command, args []string) error { func init() { delCmd.Flags().BoolP("force", "f", false, "Force delete without confirmation") - delCmd.Flags().BoolP("glob", "g", false, "Treat KEY arguments as glob patterns") + delCmd.Flags().StringSliceP("glob", "g", nil, "Delete keys matching glob pattern (repeatable)") + delCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) rootCmd.AddCommand(delCmd) } @@ -139,49 +148,55 @@ func formatKeyForPrompt(store *Store, arg string) (string, error) { return fmt.Sprintf("%s@%s", arg, db), nil } -func resolveDeleteTargets(store *Store, args []string, useGlob bool) ([]string, []string, error) { - if !useGlob { - targetKeys := make([]string, 0, len(args)) - for _, arg := range args { - exists, err := keyExists(store, arg) - if err != nil { - return nil, nil, fmt.Errorf("cannot remove '%s': %v", arg, err) - } - if !exists { - return nil, nil, fmt.Errorf("cannot remove '%s': No such key", arg) - } - targetKey, err := formatKeyForPrompt(store, arg) - if err != nil { - return nil, nil, err - } - targetKeys = append(targetKeys, targetKey) +func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, separators []rune) ([]string, []string, error) { + targetSet := make(map[string]struct{}) + var targetKeys []string + var deleteTargets []string + + for _, arg := range exactArgs { + exists, err := keyExists(store, arg) + if err != nil { + return nil, nil, fmt.Errorf("cannot remove '%s': %v", arg, err) } - return targetKeys, args, nil + if !exists { + return nil, nil, fmt.Errorf("cannot remove '%s': No such key", arg) + } + formatted, err := formatKeyForPrompt(store, arg) + if err != nil { + return nil, nil, err + } + if _, seen := targetSet[arg]; !seen { + targetSet[arg] = struct{}{} + targetKeys = append(targetKeys, formatted) + deleteTargets = append(deleteTargets, arg) + } + } + + if len(globPatterns) == 0 { + return targetKeys, deleteTargets, nil } type compiledPattern struct { rawArg string db string matcher glob.Glob - pattern string } var compiled []compiledPattern - for _, arg := range args { - kb, db, err := store.parse(arg, true) + for _, raw := range globPatterns { + kb, db, err := store.parse(raw, true) if err != nil { return nil, nil, err } pattern := string(kb) - m, err := glob.Compile(pattern) + m, err := glob.Compile(pattern, separators...) if err != nil { - return nil, nil, fmt.Errorf("cannot remove '%s': %v", arg, err) + return nil, nil, fmt.Errorf("cannot remove '%s': %v", raw, err) } compiled = append(compiled, compiledPattern{ - rawArg: arg, + rawArg: raw, db: db, matcher: m, - pattern: pattern, }) } @@ -198,36 +213,31 @@ func resolveDeleteTargets(store *Store, args []string, useGlob bool) ([]string, return keys, nil } - targetSet := make(map[string]struct{}) - var targetKeys []string - var deleteTargets []string - for _, p := range compiled { keys, err := getKeys(p.db) if err != nil { return nil, nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err) } - var matched []string + var matched bool for _, k := range keys { if p.matcher.Match(k) { - matched = append(matched, fmt.Sprintf("%s@%s", k, p.db)) + matched = true + full := fmt.Sprintf("%s@%s", k, p.db) + if _, seen := targetSet[full]; seen { + continue + } + targetSet[full] = struct{}{} + display, err := formatKeyForPrompt(store, full) + if err != nil { + return nil, nil, err + } + targetKeys = append(targetKeys, display) + deleteTargets = append(deleteTargets, full) } } - if len(matched) == 0 { + if !matched { return nil, nil, fmt.Errorf("cannot remove '%s': No matches for pattern", p.rawArg) } - for _, full := range matched { - if _, seen := targetSet[full]; seen { - continue - } - targetSet[full] = struct{}{} - display, err := formatKeyForPrompt(store, full) - if err != nil { - return nil, nil, err - } - targetKeys = append(targetKeys, display) - deleteTargets = append(deleteTargets, full) - } } return targetKeys, deleteTargets, nil diff --git a/cmd/glob.go b/cmd/glob.go new file mode 100644 index 0000000..73da54a --- /dev/null +++ b/cmd/glob.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/cobra" +) + +var defaultGlobSeparators = []rune{'/', '-', '_', '.', '@', ':', ' '} + +func defaultGlobSeparatorsDisplay() string { + var b strings.Builder + for _, r := range defaultGlobSeparators { + b.WriteRune(r) + } + return b.String() +} + +func parseGlobSeparators(cmd *cobra.Command) ([]rune, error) { + sepStr, err := cmd.Flags().GetString("glob-sep") + if err != nil { + return nil, err + } + if sepStr == "" { + return defaultGlobSeparators, nil + } + return []rune(sepStr), nil +} diff --git a/cmd/list.go b/cmd/list.go index 015130e..63e42c7 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -24,8 +24,10 @@ package cmd import ( "errors" "fmt" + "strings" "github.com/dgraph-io/badger/v4" + "github.com/gobwas/glob" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) @@ -63,6 +65,35 @@ func list(cmd *cobra.Command, args []string) error { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) } + globPatterns, err := cmd.Flags().GetStringSlice("glob") + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + separators, err := parseGlobSeparators(cmd) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + var matchers []glob.Glob + for _, pattern := range globPatterns { + m, err := glob.Compile(strings.ToLower(pattern), separators...) + if err != nil { + return fmt.Errorf("cannot ls '%s': %v", targetDB, err) + } + matchers = append(matchers, m) + } + + matchesKey := func(k string) bool { + if len(matchers) == 0 { + return true + } + for _, m := range matchers { + if m.Match(k) { + return true + } + } + return false + } + columnKinds, err := requireColumns(flags) if err != nil { return fmt.Errorf("cannot ls '%s': %v", targetDB, err) @@ -89,6 +120,7 @@ func list(cmd *cobra.Command, args []string) error { } placeholder := "**********" + var matchedCount int trans := TransactionArgs{ key: targetDB, readonly: true, @@ -103,6 +135,10 @@ func list(cmd *cobra.Command, args []string) error { for it.Rewind(); it.Valid(); it.Next() { item := it.Item() key := string(item.KeyCopy(nil)) + if !matchesKey(key) { + continue + } + matchedCount++ meta := item.UserMeta() isSecret := meta&metaSecret != 0 @@ -143,6 +179,10 @@ func list(cmd *cobra.Command, args []string) error { return err } + if len(matchers) > 0 && matchedCount == 0 { + return fmt.Errorf("cannot ls '%s': No matches for pattern", targetDB) + } + applyColumnConstraints(tw, columnKinds, output, maxContentWidths) flags.render(tw) @@ -157,5 +197,7 @@ func init() { listCmd.Flags().BoolVarP(&ttl, "ttl", "t", false, "append a TTL column when entries expire") listCmd.Flags().BoolVar(&header, "header", false, "include header row") listCmd.Flags().VarP(&format, "format", "o", "output format (table|tsv|csv|markdown|html)") + listCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") + listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) rootCmd.AddCommand(listCmd) } diff --git a/testdata/del__glob__mixed__ok.ct b/testdata/del__glob__mixed__ok.ct new file mode 100644 index 0000000..b87967e --- /dev/null +++ b/testdata/del__glob__mixed__ok.ct @@ -0,0 +1,10 @@ +$ pda set foo 1 +$ pda set bar1 2 +$ pda set bar2 3 +$ pda del foo --glob bar* --force +$ pda get foo --> FAIL +Error: cannot get 'foo': Key not found +$ pda get bar1 --> FAIL +Error: cannot get 'bar1': Key not found +$ pda get bar2 --> FAIL +Error: cannot get 'bar2': Key not found diff --git a/testdata/help__del__ok.ct b/testdata/help__del__ok.ct index 38d5e32..041dcc7 100644 --- a/testdata/help__del__ok.ct +++ b/testdata/help__del__ok.ct @@ -9,9 +9,10 @@ Aliases: del, delete, rm, remove Flags: - -f, --force Force delete without confirmation - -g, --glob Treat KEY arguments as glob patterns - -h, --help help for del + -f, --force Force delete without confirmation + -g, --glob strings Delete keys matching glob pattern (repeatable) + --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + -h, --help help for del Delete one or more keys. Optionally specify a db. Usage: @@ -21,6 +22,7 @@ Aliases: del, delete, rm, remove Flags: - -f, --force Force delete without confirmation - -g, --glob Treat KEY arguments as glob patterns - -h, --help help for del + -f, --force Force delete without confirmation + -g, --glob strings Delete keys matching glob pattern (repeatable) + --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + -h, --help help for del diff --git a/testdata/help__list__ok.ct b/testdata/help__list__ok.ct index 4b3f714..349f4cb 100644 --- a/testdata/help__list__ok.ct +++ b/testdata/help__list__ok.ct @@ -9,14 +9,16 @@ Aliases: list, ls Flags: - -b, --binary include binary data in text output - -o, --format format output format (table|tsv|csv|markdown|html) (default table) - --header include header row - -h, --help help for list - --no-keys suppress the key column - --no-values suppress the value column - -S, --secret display values marked as secret - -t, --ttl append a TTL column when entries expire + -b, --binary include binary data in text output + -o, --format format output format (table|tsv|csv|markdown|html) (default table) + -g, --glob strings Filter keys with glob pattern (repeatable) + --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --header include header row + -h, --help help for list + --no-keys suppress the key column + --no-values suppress the value column + -S, --secret display values marked as secret + -t, --ttl append a TTL column when entries expire List the contents of a db. Usage: @@ -26,11 +28,13 @@ Aliases: list, ls Flags: - -b, --binary include binary data in text output - -o, --format format output format (table|tsv|csv|markdown|html) (default table) - --header include header row - -h, --help help for list - --no-keys suppress the key column - --no-values suppress the value column - -S, --secret display values marked as secret - -t, --ttl append a TTL column when entries expire + -b, --binary include binary data in text output + -o, --format format output format (table|tsv|csv|markdown|html) (default table) + -g, --glob strings Filter keys with glob pattern (repeatable) + --glob-sep string Characters treated as separators for globbing (default "/-_.@: ") + --header include header row + -h, --help help for list + --no-keys suppress the key column + --no-values suppress the value column + -S, --secret display values marked as secret + -t, --ttl append a TTL column when entries expire diff --git a/testdata/list__glob__ok.ct b/testdata/list__glob__ok.ct new file mode 100644 index 0000000..4dcba80 --- /dev/null +++ b/testdata/list__glob__ok.ct @@ -0,0 +1,10 @@ +$ pda set a1@lg 1 +$ pda set a2@lg 2 +$ pda set b1@lg 3 +$ pda ls lg --glob a* --format tsv +a1 1 +a2 2 +$ pda ls lg --glob b* --format tsv +b1 3 +$ pda ls lg --glob c* --> FAIL +Error: cannot ls '@lg': No matches for pattern