feat(globs): glob support extended to ls and documented in README

This commit is contained in:
Lewis Wynne 2025-12-17 19:40:05 +00:00
parent badbf3b6bb
commit 95c9ac8fca
8 changed files with 316 additions and 74 deletions

146
README.md
View file

@ -34,6 +34,7 @@
- [Installation](https://github.com/Llywelwyn/pda#installation) - [Installation](https://github.com/Llywelwyn/pda#installation)
- [Get Started](https://github.com/Llywelwyn/pda#get-started) - [Get Started](https://github.com/Llywelwyn/pda#get-started)
- [Templates](https://github.com/Llywelwyn/pda#templates) - [Templates](https://github.com/Llywelwyn/pda#templates)
- [Globs](https://github.com/Llywelwyn/pda#globs)
- [Secrets](https://github.com/Llywelwyn/pda#secrets) - [Secrets](https://github.com/Llywelwyn/pda#secrets)
- [TTL](https://github.com/Llywelwyn/pda#ttl) - [TTL](https://github.com/Llywelwyn/pda#ttl)
- [Binary](https://github.com/Llywelwyn/pda#binary) - [Binary](https://github.com/Llywelwyn/pda#binary)
@ -129,15 +130,22 @@ pda del kitty
# remove "kitty": are you sure? [y/n] # remove "kitty": are you sure? [y/n]
# y # y
# Or skip the prompt.
pda del kitty --force
# Remove multiple keys, within the same or different stores.
pda del kitty dog@animals pda del kitty dog@animals
# remove "kitty", "dog@animals": are you sure? [y/n] # remove "kitty", "dog@animals": are you sure? [y/n]
# y # y
# Or skip the prompt. # Mix exact keys with globs.
pda del kitty --force pda set cog "cogs"
pda set dog "doggy"
# Delete many in one go. pda set kitty "cat"
pda del kitty dogs cats --force pda del kitty --glob ?og
# remove "kitty", "cog", "dog": are you sure? [y/n]
# y
# Default glob separators: "/-_.@:". Override with --glob-sep.
``` ```
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
@ -300,6 +308,134 @@ pda get hello --no-template
<p align="center"></p><!-- spacer --> <p align="center"></p><!-- spacer -->
### 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.
<p align="center"></p><!-- spacer -->
`*` 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
```
<p align="center"></p><!-- spacer -->
`**` super-wildcards ignore word boundaries.
```bash
pda ls --glob "foo**"
# foo.bar.baz
pda ls --glob "**g"
# dog
# cog
# mouse hotdog
```
<p align="center"></p><!-- spacer -->
`?` wildcards a single letter.
```bash
pda ls --glob ?og
# dog
# cog
# frog --> fail
# dogs --> fail
```
<p align="center"></p><!-- spacer -->
`[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
```
<p align="center"></p><!-- spacer -->
`[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
```
<p align="center"></p><!-- spacer -->
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]
```
<p align="center"></p><!-- spacer -->
`--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
```
<p align="center"></p><!-- spacer -->
### Secrets ### Secrets
Mark sensitive values with `secret` to stop accidents. Mark sensitive values with `secret` to stop accidents.

View file

@ -36,7 +36,7 @@ var delCmd = &cobra.Command{
Use: "del KEY[@DB] [KEY[@DB] ...]", Use: "del KEY[@DB] [KEY[@DB] ...]",
Short: "Delete one or more keys. Optionally specify a db.", Short: "Delete one or more keys. Optionally specify a db.",
Aliases: []string{"delete", "rm", "remove"}, Aliases: []string{"delete", "rm", "remove"},
Args: cobra.MinimumNArgs(1), Args: cobra.ArbitraryArgs,
RunE: del, RunE: del,
SilenceUsage: true, SilenceUsage: true,
} }
@ -48,12 +48,20 @@ func del(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
@ -100,7 +108,8 @@ func del(cmd *cobra.Command, args []string) error {
func init() { func init() {
delCmd.Flags().BoolP("force", "f", false, "Force delete without confirmation") 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) rootCmd.AddCommand(delCmd)
} }
@ -139,49 +148,55 @@ func formatKeyForPrompt(store *Store, arg string) (string, error) {
return fmt.Sprintf("%s@%s", arg, db), nil return fmt.Sprintf("%s@%s", arg, db), nil
} }
func resolveDeleteTargets(store *Store, args []string, useGlob bool) ([]string, []string, error) { func resolveDeleteTargets(store *Store, exactArgs []string, globPatterns []string, separators []rune) ([]string, []string, error) {
if !useGlob { targetSet := make(map[string]struct{})
targetKeys := make([]string, 0, len(args)) var targetKeys []string
for _, arg := range args { var deleteTargets []string
exists, err := keyExists(store, arg)
if err != nil { for _, arg := range exactArgs {
return nil, nil, fmt.Errorf("cannot remove '%s': %v", arg, err) exists, err := keyExists(store, arg)
} if err != nil {
if !exists { return nil, nil, fmt.Errorf("cannot remove '%s': %v", arg, err)
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)
} }
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 { type compiledPattern struct {
rawArg string rawArg string
db string db string
matcher glob.Glob matcher glob.Glob
pattern string
} }
var compiled []compiledPattern var compiled []compiledPattern
for _, arg := range args { for _, raw := range globPatterns {
kb, db, err := store.parse(arg, true) kb, db, err := store.parse(raw, true)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
pattern := string(kb) pattern := string(kb)
m, err := glob.Compile(pattern) m, err := glob.Compile(pattern, separators...)
if err != nil { 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{ compiled = append(compiled, compiledPattern{
rawArg: arg, rawArg: raw,
db: db, db: db,
matcher: m, matcher: m,
pattern: pattern,
}) })
} }
@ -198,36 +213,31 @@ func resolveDeleteTargets(store *Store, args []string, useGlob bool) ([]string,
return keys, nil return keys, nil
} }
targetSet := make(map[string]struct{})
var targetKeys []string
var deleteTargets []string
for _, p := range compiled { for _, p := range compiled {
keys, err := getKeys(p.db) keys, err := getKeys(p.db)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err) return nil, nil, fmt.Errorf("cannot remove '%s': %v", p.rawArg, err)
} }
var matched []string var matched bool
for _, k := range keys { for _, k := range keys {
if p.matcher.Match(k) { 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) 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 return targetKeys, deleteTargets, nil

28
cmd/glob.go Normal file
View file

@ -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
}

View file

@ -24,8 +24,10 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/dgraph-io/badger/v4" "github.com/dgraph-io/badger/v4"
"github.com/gobwas/glob"
"github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra" "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) 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) columnKinds, err := requireColumns(flags)
if err != nil { if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
@ -89,6 +120,7 @@ func list(cmd *cobra.Command, args []string) error {
} }
placeholder := "**********" placeholder := "**********"
var matchedCount int
trans := TransactionArgs{ trans := TransactionArgs{
key: targetDB, key: targetDB,
readonly: true, readonly: true,
@ -103,6 +135,10 @@ func list(cmd *cobra.Command, args []string) error {
for it.Rewind(); it.Valid(); it.Next() { for it.Rewind(); it.Valid(); it.Next() {
item := it.Item() item := it.Item()
key := string(item.KeyCopy(nil)) key := string(item.KeyCopy(nil))
if !matchesKey(key) {
continue
}
matchedCount++
meta := item.UserMeta() meta := item.UserMeta()
isSecret := meta&metaSecret != 0 isSecret := meta&metaSecret != 0
@ -143,6 +179,10 @@ func list(cmd *cobra.Command, args []string) error {
return err return err
} }
if len(matchers) > 0 && matchedCount == 0 {
return fmt.Errorf("cannot ls '%s': No matches for pattern", targetDB)
}
applyColumnConstraints(tw, columnKinds, output, maxContentWidths) applyColumnConstraints(tw, columnKinds, output, maxContentWidths)
flags.render(tw) 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().BoolVarP(&ttl, "ttl", "t", false, "append a TTL column when entries expire")
listCmd.Flags().BoolVar(&header, "header", false, "include header row") listCmd.Flags().BoolVar(&header, "header", false, "include header row")
listCmd.Flags().VarP(&format, "format", "o", "output format (table|tsv|csv|markdown|html)") 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) rootCmd.AddCommand(listCmd)
} }

10
testdata/del__glob__mixed__ok.ct vendored Normal file
View file

@ -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

View file

@ -9,9 +9,10 @@ Aliases:
del, delete, rm, remove del, delete, rm, remove
Flags: Flags:
-f, --force Force delete without confirmation -f, --force Force delete without confirmation
-g, --glob Treat KEY arguments as glob patterns -g, --glob strings Delete keys matching glob pattern (repeatable)
-h, --help help for del --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
-h, --help help for del
Delete one or more keys. Optionally specify a db. Delete one or more keys. Optionally specify a db.
Usage: Usage:
@ -21,6 +22,7 @@ Aliases:
del, delete, rm, remove del, delete, rm, remove
Flags: Flags:
-f, --force Force delete without confirmation -f, --force Force delete without confirmation
-g, --glob Treat KEY arguments as glob patterns -g, --glob strings Delete keys matching glob pattern (repeatable)
-h, --help help for del --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
-h, --help help for del

View file

@ -9,14 +9,16 @@ Aliases:
list, ls list, ls
Flags: Flags:
-b, --binary include binary data in text output -b, --binary include binary data in text output
-o, --format format output format (table|tsv|csv|markdown|html) (default table) -o, --format format output format (table|tsv|csv|markdown|html) (default table)
--header include header row -g, --glob strings Filter keys with glob pattern (repeatable)
-h, --help help for list --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
--no-keys suppress the key column --header include header row
--no-values suppress the value column -h, --help help for list
-S, --secret display values marked as secret --no-keys suppress the key column
-t, --ttl append a TTL column when entries expire --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. List the contents of a db.
Usage: Usage:
@ -26,11 +28,13 @@ Aliases:
list, ls list, ls
Flags: Flags:
-b, --binary include binary data in text output -b, --binary include binary data in text output
-o, --format format output format (table|tsv|csv|markdown|html) (default table) -o, --format format output format (table|tsv|csv|markdown|html) (default table)
--header include header row -g, --glob strings Filter keys with glob pattern (repeatable)
-h, --help help for list --glob-sep string Characters treated as separators for globbing (default "/-_.@: ")
--no-keys suppress the key column --header include header row
--no-values suppress the value column -h, --help help for list
-S, --secret display values marked as secret --no-keys suppress the key column
-t, --ttl append a TTL column when entries expire --no-values suppress the value column
-S, --secret display values marked as secret
-t, --ttl append a TTL column when entries expire

10
testdata/list__glob__ok.ct vendored Normal file
View file

@ -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