Skip to content

Commit

Permalink
Support prefix filtering with multiple OR prefixes such as: ./tips "f…
Browse files Browse the repository at this point in the history
…oo | bar"
  • Loading branch information
deckarep committed Jan 8, 2024
1 parent 2e512a4 commit 3c550b9
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 21 deletions.
6 changes: 3 additions & 3 deletions cmd/utility.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ func packageCfg(args []string) (*pkg.ConfigCtx, error) {
// The 0th arg is the Primary filter, if nothing was specified we consider it to represent: @ for all
if len(args) > 0 {
if strings.TrimSpace(args[0]) == allFilterCLI {
cfgCtx.PrefixFilter = allFilter
cfgCtx.PrefixFilter = pkg.ParsePrefixFilter("*")
} else {
cfgCtx.PrefixFilter = args[0]
cfgCtx.PrefixFilter = pkg.ParsePrefixFilter(args[0])
}
} else {
cfgCtx.PrefixFilter = allFilter
cfgCtx.PrefixFilter = pkg.ParsePrefixFilter("*")
}

// The 1st arg along with the rest - [1:] when provided is a remote command to execute.
Expand Down
2 changes: 1 addition & 1 deletion cmd/utility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestPackageCfg(t *testing.T) {
assert.NoError(t, err)
assert.NotNil(t, cfg)

assert.Equal(t, cfg.PrefixFilter, "*")
assert.True(t, cfg.PrefixFilter.IsAll())
assert.Equal(t, cfg.RemoteCmd, "echo 'hello world' && sleep 0.5 && ps aux | grep foo")
}

Expand Down
36 changes: 35 additions & 1 deletion pkg/config_ctx.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ SOFTWARE.
package pkg

import (
"slices"
"strconv"

"github.com/charmbracelet/log"
Expand Down Expand Up @@ -113,7 +114,7 @@ type ConfigCtx struct {
JsonOutput bool
NoCache bool
NoColor bool
PrefixFilter string // We'll do prefix filtering in Boltdb
PrefixFilter *PrefixFilter
RemoteCmd string
Slice *SliceCfg
SortOrder []SortSpec
Expand Down Expand Up @@ -148,3 +149,36 @@ func ParseColumns(s string) mapset.Set[string] {

return m
}

type PrefixFilter struct {
originalQuery string
orPrefixes []string
}

func (p *PrefixFilter) IsAll() bool {
return len(p.orPrefixes) == 1 && (p.orPrefixes[0] == "*" || p.orPrefixes[0] == "@")
}

func (p *PrefixFilter) Count() int {
return len(p.orPrefixes)
}

func (p *PrefixFilter) PrefixAt(idx int) string {
return p.orPrefixes[idx]
}

func ParsePrefixFilter(s string) *PrefixFilter {
var prefixes []string
parts := strings.Split(s, "|")
for _, p := range parts {
prefixes = append(prefixes, strings.TrimSpace(p))
}

slices.Sort(prefixes)

pf := &PrefixFilter{
originalQuery: s,
orPrefixes: prefixes,
}
return pf
}
26 changes: 26 additions & 0 deletions pkg/config_ctx_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package pkg

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestParseSlice(t *testing.T) {
Expand Down Expand Up @@ -64,3 +66,27 @@ func TestParseSlice(t *testing.T) {
t.Error("expected slice to be defined")
}
}

func TestParsePrefixFilter(t *testing.T) {
// The prefix for everything prefix
pf := ParsePrefixFilter("*")
assert.NotNil(t, pf)
assert.True(t, pf.IsAll())

// The prefix for everything prefix (the same thing)
pf = ParsePrefixFilter("@")
assert.NotNil(t, pf)
assert.True(t, pf.IsAll())

// The prefix with multiple OR conditions
pf = ParsePrefixFilter("foo|bar | baz")
assert.NotNil(t, pf)
assert.False(t, pf.IsAll())

assert.Equal(t, pf.Count(), 3)

expectedPrefixOrder := []string{"bar", "baz", "foo"}
for i := 0; i < pf.Count(); i++ {
assert.Equal(t, pf.PrefixAt(i), expectedPrefixOrder[i])
}
}
32 changes: 18 additions & 14 deletions pkg/db_generic.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,9 @@ func (d *DB2[T]) IndexOpaqueItems(ctx context.Context, bucketName string, items
return nil
}

type DBQuery2 struct {
PrefixFilter string
PrimaryKeys []string
type DBQuery struct {
PrefixFilters *PrefixFilter
PrimaryKeys []string
}

func (d *DB2[T]) LookupOpaqueItem(ctx context.Context, bucketName, primaryKey string) (*T, error) {
Expand Down Expand Up @@ -181,10 +181,10 @@ func (d *DB2[T]) lookupOpaqueItem(bucket *bolt.Bucket, primaryKey string) (*T, e
// 1. Using one or more primary keys, in which case this is a direct lookup (not technically a search)
// 2. Using the * (all/everything) construct, this is just a full table scan really.
// 3. Using a prefix scan, this is a seek to a segment of the index and should be fast assuming good selectivity.
func (d *DB2[T]) SearchOpaqueItems(ctx context.Context, bucketName string, query DBQuery2) ([]T, error) {
func (d *DB2[T]) SearchOpaqueItems(ctx context.Context, bucketName string, query DBQuery) ([]T, error) {
//cfg := CtxAsConfig(ctx, CtxKeyConfig)

if strings.TrimSpace(query.PrefixFilter) == "" {
if query.PrefixFilters.Count() == 0 {
panic("query.PrefixFilter must never be empty, in the case of all it must be: *")
}

Expand All @@ -196,7 +196,6 @@ func (d *DB2[T]) SearchOpaqueItems(ctx context.Context, bucketName string, query
return errors.New("bucket is unknown: " + bucketName)
}

c := b.Cursor()
// Search by primary keys, this a direct lookup, the fastest.
if len(query.PrimaryKeys) > 0 {
for _, pk := range query.PrimaryKeys {
Expand All @@ -206,7 +205,8 @@ func (d *DB2[T]) SearchOpaqueItems(ctx context.Context, bucketName string, query
}
items = append(items, *item)
}
} else if query.PrefixFilter == "*" {
} else if query.PrefixFilters.IsAll() {
c := b.Cursor()
// Search by everything, linear (full-table scan)
for k, v := c.First(); k != nil; k, v = c.Next() {
var item T
Expand All @@ -216,14 +216,18 @@ func (d *DB2[T]) SearchOpaqueItems(ctx context.Context, bucketName string, query
items = append(items, item)
}
} else {
// Prefix scan (much faster) when a prefix is present.
prefix := []byte(query.PrefixFilter)
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
var item T
if err := json.Unmarshal(v, &item); err != nil {
return err
// Since Prefix Filters support OR filters: "foo|bar" we do this in a loop with a new cursor for each prefix.
// Most of the time, just one iteration occurs.
for i := 0; i < query.PrefixFilters.Count(); i++ {
c := b.Cursor()
prefix := []byte(query.PrefixFilters.PrefixAt(i))
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
var item T
if err := json.Unmarshal(v, &item); err != nil {
return err
}
items = append(items, item)
}
items = append(items, item)
}
}

Expand Down
4 changes: 2 additions & 2 deletions pkg/repository_cached.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func (c *CachedRepository) DevicesResource(ctx context.Context) ([]*WrappedDevic

// Care is taken to measure just cache retrieval time.
cachedStartTime := time.Now()
devList, err := deviceIndexedRepo.SearchOpaqueItems(ctx, DevicesBucket, DBQuery2{PrefixFilter: cfg.PrefixFilter})
devList, err := deviceIndexedRepo.SearchOpaqueItems(ctx, DevicesBucket, DBQuery{PrefixFilters: cfg.PrefixFilter})
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -105,7 +105,7 @@ func (c *CachedRepository) DevicesResource(ctx context.Context) ([]*WrappedDevic

// 4. Return the data from the db because the db can utilize the index on prefix filters.
// In the future it may also do other heavyweight filters that we don't have to do in "user space"
devList, err = deviceIndexedRepo.SearchOpaqueItems(ctx, DevicesBucket, DBQuery2{PrefixFilter: cfg.PrefixFilter})
devList, err = deviceIndexedRepo.SearchOpaqueItems(ctx, DevicesBucket, DBQuery{PrefixFilters: cfg.PrefixFilter})
if err != nil {
return nil, err
}
Expand Down

0 comments on commit 3c550b9

Please sign in to comment.