From 89ac00e64e2b834ff371753daaf13da1d719c783 Mon Sep 17 00:00:00 2001 From: ashmrtn Date: Mon, 10 Apr 2023 12:11:53 -0700 Subject: [PATCH] Add basic prefix matcher type (#3054) Adds a basic generic prefix matcher type. The first implementation just uses a map and iterates through things This is expected to be used for at least the exclude list for backups (inner type of `map[string]struct{}`) and logic for merging backup details (inner type still TBD a bit, but either `*path.Builder` or a custom struct) --- #### Does this PR need a docs update or release note? - [ ] :white_check_mark: Yes, it's included - [ ] :clock1: Yes, but in a later PR - [x] :no_entry: No #### Type of change - [x] :sunflower: Feature - [ ] :bug: Bugfix - [ ] :world_map: Documentation - [ ] :robot: Supportability/Tests - [ ] :computer: CI/Deployment - [ ] :broom: Tech Debt/Cleanup #### Issue(s) * #2486 #### Test Plan - [ ] :muscle: Manual - [x] :zap: Unit test - [ ] :green_heart: E2E --- .../common/prefixmatcher/prefix_matcher.go | 68 ++++++++++ .../prefixmatcher/prefix_matcher_test.go | 127 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/internal/common/prefixmatcher/prefix_matcher.go create mode 100644 src/internal/common/prefixmatcher/prefix_matcher_test.go diff --git a/src/internal/common/prefixmatcher/prefix_matcher.go b/src/internal/common/prefixmatcher/prefix_matcher.go new file mode 100644 index 000000000..cb244cf26 --- /dev/null +++ b/src/internal/common/prefixmatcher/prefix_matcher.go @@ -0,0 +1,68 @@ +package prefixmatcher + +import ( + "strings" +) + +type View[T any] interface { + Get(key string) (T, bool) + LongestPrefix(key string) (string, T, bool) + Empty() bool +} + +type Matcher[T any] interface { + // Add adds or updates the item with key to have value value. + Add(key string, value T) + View[T] +} + +type prefixMatcher[T any] struct { + data map[string]T +} + +func (m *prefixMatcher[T]) Add(key string, value T) { + m.data[key] = value +} + +func (m *prefixMatcher[T]) Get(key string) (T, bool) { + if m == nil { + return *new(T), false + } + + res, ok := m.data[key] + + return res, ok +} + +func (m *prefixMatcher[T]) LongestPrefix(key string) (string, T, bool) { + if m == nil { + return "", *new(T), false + } + + var ( + rk string + rv T + found bool + // Set to -1 so if there's "" as a prefix ("all match") we still select it. + longest = -1 + ) + + for k, v := range m.data { + if strings.HasPrefix(key, k) && len(k) > longest { + found = true + longest = len(k) + rk = k + rv = v + } + } + + return rk, rv, found +} + +func (m prefixMatcher[T]) Empty() bool { + return len(m.data) == 0 +} + +func NewMatcher[T any]() Matcher[T] { + return &prefixMatcher[T]{data: map[string]T{}} +} diff --git a/src/internal/common/prefixmatcher/prefix_matcher_test.go b/src/internal/common/prefixmatcher/prefix_matcher_test.go new file mode 100644 index 000000000..998b0184e --- /dev/null +++ b/src/internal/common/prefixmatcher/prefix_matcher_test.go @@ -0,0 +1,127 @@ +package prefixmatcher_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/src/internal/common/prefixmatcher" + "github.com/alcionai/corso/src/internal/tester" +) + +type PrefixMatcherUnitSuite struct { + tester.Suite +} + +func TestPrefixMatcherUnitSuite(t *testing.T) { + suite.Run(t, &PrefixMatcherUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *PrefixMatcherUnitSuite) TestEmpty() { + pm := prefixmatcher.NewMatcher[string]() + assert.True(suite.T(), pm.Empty()) +} + +func (suite *PrefixMatcherUnitSuite) TestAdd_Get() { + t := suite.T() + pm := prefixmatcher.NewMatcher[string]() + kvs := map[string]string{ + "hello": "world", + "hola": "mundo", + "foo": "bar", + } + + for k, v := range kvs { + pm.Add(k, v) + } + + for k, v := range kvs { + val, ok := pm.Get(k) + assert.True(t, ok, "searching for key", k) + assert.Equal(t, v, val, "returned value") + } +} + +func (suite *PrefixMatcherUnitSuite) TestLongestPrefix() { + key := "hello" + value := "world" + + table := []struct { + name string + inputKVs map[string]string + searchKey string + expectedKey string + expectedValue string + expectedFound assert.BoolAssertionFunc + }{ + { + name: "Empty Prefix", + inputKVs: map[string]string{ + "": value, + }, + searchKey: key, + expectedKey: "", + expectedValue: value, + expectedFound: assert.True, + }, + { + name: "Exact Match", + inputKVs: map[string]string{ + key: value, + }, + searchKey: key, + expectedKey: key, + expectedValue: value, + expectedFound: assert.True, + }, + { + name: "Prefix Match", + inputKVs: map[string]string{ + key[:len(key)-2]: value, + }, + searchKey: key, + expectedKey: key[:len(key)-2], + expectedValue: value, + expectedFound: assert.True, + }, + { + name: "Longest Prefix Match", + inputKVs: map[string]string{ + key[:len(key)-2]: value, + "": value + "2", + key[:len(key)-4]: value + "3", + }, + searchKey: key, + expectedKey: key[:len(key)-2], + expectedValue: value, + expectedFound: assert.True, + }, + { + name: "No Match", + inputKVs: map[string]string{ + "foo": value, + }, + searchKey: key, + expectedKey: "", + expectedValue: "", + expectedFound: assert.False, + }, + } + + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + pm := prefixmatcher.NewMatcher[string]() + + for k, v := range test.inputKVs { + pm.Add(k, v) + } + + k, v, ok := pm.LongestPrefix(test.searchKey) + assert.Equal(t, test.expectedKey, k, "key") + assert.Equal(t, test.expectedValue, v, "value") + test.expectedFound(t, ok, "found") + }) + } +}