diff --git a/src/pkg/selectors/scopes.go b/src/pkg/selectors/scopes.go new file mode 100644 index 000000000..924593c8d --- /dev/null +++ b/src/pkg/selectors/scopes.go @@ -0,0 +1,151 @@ +package selectors + +import ( + "github.com/alcionai/corso/pkg/backup/details" +) + +// --------------------------------------------------------------------------- +// interfaces +// --------------------------------------------------------------------------- + +type ( + // categorizer recognizes service specific item categories. + categorizer interface { + // String should return the human readable name of the category. + String() string + + // includesType should return true if the parameterized category is, contextually + // within the service, a subset of the receiver category. Ex: a Mail category + // is a subset of a MailFolder category. + includesType(categorizer) bool + + // pathValues should produce a map of category:string pairs populated by extracting + // values out of the path that match the given categorizer. + // + // Ex: given a path like "tenant/service/root/dataType/folder/itemID", the func should + // autodetect the data type using 'service' and 'dataType', and use the remaining + // details to construct a map similar to this: + // { + // rootCat: root, + // folderCat: folder, + // itemCat: itemID, + // } + pathValues([]string) map[categorizer]string + + // pathKeys produces a list of categorizers that can be used as keys in the pathValues + // map. The combination of the two funcs generically interprets the context of the + // ids in a path with the same keys that it uses to retrieve those values from a scope, + // so that the two can be compared. + pathKeys() []categorizer + } + // TODO: Uncomment when reducer func is added + // categoryT is the generic type interface of a categorizer + // categoryT interface { + // ~int + // categorizer + // } +) + +type ( + // scopes are generic containers that hold comparable values and other metadata expressing + // "the data to match on". The matching behaviors that utilize scopes are: Inclusion (any- + // match), Filter (all-match), and Exclusion (any-match). + // + // The values in a scope fall into one of two categories: comparables and metadata. + // + // Comparable values should be keyed by a categorizer.String() value, where that categorizer + // is identified by the category set for the given service. These values will be used in + // path value comparisons (where the categorizer.pathValues() of the same key must match the + // scope values), and details.Entry comparisons (where some entry.ServiceInfo is related to + // the scope value). Comparable values can also express a wildcard match (AnyTgt) or a no- + // match (NoneTgt). + // + // Metadata values express details that are common across all service instances: data + // granularity (group or item), resource (id of the root path resource), core data type + // (human readable), or whether the scope is a filter-type or an inclusion-/exclusion-type. + // Metadata values can be used in either logical processing of scopes, and/or for presentation + // to end users. + scope map[string]string + + // scoper describes the minimum necessary interface that a soundly built scope should + // comply with. + scoper interface { + // Every scope is expected to contain a reference to its category. This allows users + // to evaluate structs with a call to myscope.Category(). Category() is expected to + // return the service-specific type of the categorizer, since the end user is expected + // to be operating within that context. + // This func returns the same value as the categorizer interface so that the funcs + // internal to scopes.go can utilize the scope's category without the service context. + categorizer() categorizer + + // matchesEntry is used to determine if the scope values match with either the pathValues, + // or the DetailsEntry for the given category. + // The path comparison (using cat and pathValues) can be handled generically within + // scopes.go. However, the entry comparison requires service-specific context in order + // for the scope to extract the correct serviceInfo in the entry. + // + // Params: + // cat - the category type expressed in the Path. Not the category of the Scope. If the + // scope does not align with this parameter, the result is automatically false. + // pathValues - the result of categorizer.pathValues() for the Path being checked. + // entry - the details entry containing extended service info for the item that a filter may + // compare. Identification of the correct entry Info service is left up to the scope. + matchesEntry(cat categorizer, pathValues map[categorizer]string, entry details.DetailsEntry) bool + } + // scopeT is the generic type interface of a scoper. + scopeT interface { + ~map[string]string + scoper + } +) + +// --------------------------------------------------------------------------- +// funcs +// --------------------------------------------------------------------------- + +// TODO: Uncomment when selectors.go/contains() can be removed. +// +// contains returns true if the category is included in the scope's +// data type, and the target string is included in the scope. +// func contains[T scopeT](s T, cat categorizer, target string) bool { +// if !s.categorizer().includesType(cat) { +// return false +// } +// compare := s[cat.String()] +// if len(compare) == 0 { +// return false +// } +// if compare == NoneTgt { +// return false +// } +// if compare == AnyTgt { +// return true +// } +// return strings.Contains(compare, target) +// } + +// getCatValue takes the value of s[cat], split it by the standard +// delimiter, and returns the slice. If s[cat] is nil, returns +// None(). +func getCatValue[T scopeT](s T, cat categorizer) []string { + v, ok := s[cat.String()] + if !ok { + return None() + } + return split(v) +} + +// granularity describes the granularity (directory || item) +// of the data in scope. +func granularity[T scopeT](s T) string { + return s[scopeKeyGranularity] +} + +// returns true if the category is included in the scope's category type, +// and the value is set to Any(). +func isAnyTarget[T scopeT](s T, cat categorizer) bool { + if !s.categorizer().includesType(cat) { + return false + } + return s[cat.String()] == AnyTgt +} diff --git a/src/pkg/selectors/scopes_test.go b/src/pkg/selectors/scopes_test.go new file mode 100644 index 000000000..6f89c8a2a --- /dev/null +++ b/src/pkg/selectors/scopes_test.go @@ -0,0 +1,158 @@ +package selectors + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/pkg/backup/details" +) + +// --------------------------------------------------------------------------- +// consts and mocks +// --------------------------------------------------------------------------- + +// categorizer +type mockCategorizer int + +const ( + unknownCatStub mockCategorizer = iota + rootCatStub + leafCatStub +) + +var _ categorizer = unknownCatStub + +func (sc mockCategorizer) String() string { + switch sc { + case leafCatStub: + return "leaf" + case rootCatStub: + return "root" + } + return "unknown" +} + +func (sc mockCategorizer) includesType(cat categorizer) bool { + switch sc { + case rootCatStub: + return cat == rootCatStub + case leafCatStub: + return true + } + return false +} + +func (sc mockCategorizer) pathValues(path []string) map[categorizer]string { + return map[categorizer]string{rootCatStub: "stub"} +} + +func (sc mockCategorizer) pathKeys() []categorizer { + return []categorizer{rootCatStub, leafCatStub} +} + +// TODO: Uncomment when reducer func is added +// func stubPathValues() map[categorizer]string { +// return map[categorizer]string{ +// rootCatStub: rootCatStub.String(), +// leafCatStub: leafCatStub.String(), +// } +// } + +// scoper +type mockScope scope + +var _ scoper = &mockScope{} + +func (ms mockScope) categorizer() categorizer { + switch ms[scopeKeyCategory] { + case rootCatStub.String(): + return rootCatStub + case leafCatStub.String(): + return leafCatStub + } + return unknownCatStub +} + +func (ms mockScope) matchesEntry( + cat categorizer, + pathValues map[categorizer]string, + entry details.DetailsEntry, +) bool { + return ms[shouldMatch] == "true" +} + +const ( + shouldMatch = "should-match-entry" + stubResource = "stubResource" +) + +// helper funcs +func stubScope(match string) mockScope { + sm := "true" + if len(match) > 0 { + sm = match + } + return mockScope{ + rootCatStub.String(): AnyTgt, + scopeKeyCategory: rootCatStub.String(), + scopeKeyGranularity: Item, + scopeKeyResource: stubResource, + scopeKeyDataType: rootCatStub.String(), + shouldMatch: sm, + } +} + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +type SelectorScopesSuite struct { + suite.Suite +} + +func TestSelectorScopesSuite(t *testing.T) { + suite.Run(t, new(SelectorScopesSuite)) +} + +// TODO: Uncomment when contains() is switched for the scopes.go version +// +// func (suite *SelectorScopesSuite) TestContains() { +// t := suite.T() +// // any +// stub := stubScope("") +// assert.True(t, contains(stub, rootCatStub, rootCatStub.String()), "any") +// // none +// stub[rootCatStub.String()] = NoneTgt +// assert.False(t, contains(stub, rootCatStub, rootCatStub.String()), "none") +// // missing values +// assert.False(t, contains(stub, rootCatStub, ""), "missing target") +// stub[rootCatStub.String()] = "" +// assert.False(t, contains(stub, rootCatStub, rootCatStub.String()), "missing scope value") +// // specific values +// stub[rootCatStub.String()] = rootCatStub.String() +// assert.True(t, contains(stub, rootCatStub, rootCatStub.String()), "matching value") +// assert.False(t, contains(stub, rootCatStub, "smarf"), "non-matching value") +// } + +func (suite *SelectorScopesSuite) TestGetCatValue() { + t := suite.T() + stub := stubScope("") + stub[rootCatStub.String()] = rootCatStub.String() + assert.Equal(t, []string{rootCatStub.String()}, getCatValue(stub, rootCatStub)) + assert.Equal(t, None(), getCatValue(stub, leafCatStub)) +} + +func (suite *SelectorScopesSuite) TestGranularity() { + t := suite.T() + stub := stubScope("") + assert.Equal(t, Item, granularity(stub)) +} + +func (suite *SelectorScopesSuite) TestIsAnyTarget() { + t := suite.T() + stub := stubScope("") + assert.True(t, isAnyTarget(stub, rootCatStub)) + assert.False(t, isAnyTarget(stub, leafCatStub)) +} diff --git a/src/pkg/selectors/selectors_test.go b/src/pkg/selectors/selectors_test.go index e9fbc67f0..bb7b18cd4 100644 --- a/src/pkg/selectors/selectors_test.go +++ b/src/pkg/selectors/selectors_test.go @@ -8,29 +8,12 @@ import ( "github.com/stretchr/testify/suite" ) -const ( - user = "me@my.onmicrosoft.com" -) - -var dataType = ExchangeEvent.String() - -func stubScope() map[string]string { - return map[string]string{ - ExchangeEvent.String(): AnyTgt, - ExchangeUser.String(): user, - scopeKeyCategory: dataType, - scopeKeyGranularity: Group, - scopeKeyResource: user, - scopeKeyDataType: dataType, - } -} - func stubSelector() Selector { return Selector{ Service: ServiceExchange, - Excludes: []map[string]string{stubScope()}, - Filters: []map[string]string{stubScope()}, - Includes: []map[string]string{stubScope()}, + Excludes: []map[string]string{stubScope("")}, + Filters: []map[string]string{stubScope("")}, + Includes: []map[string]string{stubScope("")}, } } @@ -79,12 +62,12 @@ func (suite *SelectorSuite) TestPrintable_IncludedResources() { p := sel.Printable() res := p.Resources() - assert.Equal(t, user, res, "resource should state only the user") + assert.Equal(t, stubResource, res, "resource should state only the user") sel.Includes = []map[string]string{ - stubScope(), - {scopeKeyResource: "smarf", scopeKeyDataType: dataType}, - {scopeKeyResource: "smurf", scopeKeyDataType: dataType}, + stubScope(""), + {scopeKeyResource: "smarf", scopeKeyDataType: unknownCatStub.String()}, + {scopeKeyResource: "smurf", scopeKeyDataType: unknownCatStub.String()}, } p = sel.Printable() res = p.Resources() @@ -94,7 +77,7 @@ func (suite *SelectorSuite) TestPrintable_IncludedResources() { p.Includes = nil res = p.Resources() - assert.Equal(t, user, res, "resource on filters should state only the user") + assert.Equal(t, stubResource, res, "resource on filters should state only the user") p.Filters = nil res = p.Resources() @@ -110,36 +93,36 @@ func (suite *SelectorSuite) TestToResourceTypeMap() { }{ { name: "single scope", - input: []map[string]string{stubScope()}, + input: []map[string]string{stubScope("")}, expect: map[string][]string{ - user: {dataType}, + stubResource: {rootCatStub.String()}, }, }, { name: "disjoint resources", input: []map[string]string{ - stubScope(), + stubScope(""), { scopeKeyResource: "smarf", - scopeKeyDataType: dataType, + scopeKeyDataType: unknownCatStub.String(), }, }, expect: map[string][]string{ - user: {dataType}, - "smarf": {dataType}, + stubResource: {rootCatStub.String()}, + "smarf": {unknownCatStub.String()}, }, }, { name: "disjoint types", input: []map[string]string{ - stubScope(), + stubScope(""), { - scopeKeyResource: user, + scopeKeyResource: stubResource, scopeKeyDataType: "other", }, }, expect: map[string][]string{ - user: {dataType, "other"}, + stubResource: {rootCatStub.String(), "other"}, }, }, } @@ -153,7 +136,7 @@ func (suite *SelectorSuite) TestToResourceTypeMap() { func (suite *SelectorSuite) TestContains() { t := suite.T() - key := "key" + key := unknownCatStub.String() target := "fnords" does := map[string]string{key: target} doesNot := map[string]string{key: "smarf"}