corso/src/pkg/selectors/scopes.go
Keepers 6f7cf00188
add cli integration tests for event/contact (#760)
## Description

Adds foundational cli integration tests for backup/restore
of events and calendars

## Type of change

- [x] 🤖 Test

## Issue(s)

#501 

## Test Plan

- [ ] 💪 Manual
- [ ]  Unit test
- [x] 💚 E2E
2022-09-09 14:15:36 +00:00

465 lines
14 KiB
Go

package selectors
import (
"strings"
"github.com/alcionai/corso/src/internal/path"
"github.com/alcionai/corso/src/pkg/backup/details"
"github.com/alcionai/corso/src/pkg/filters"
)
// ---------------------------------------------------------------------------
// interfaces
// ---------------------------------------------------------------------------
type (
// categorizer recognizes service specific item categories.
categorizer interface {
// String should return the human readable name of the category.
String() string
// leafCat should return the lowest level type matching the category. If the type
// has multiple leaf types (ex: the root category) or no leaves (ex: unknown values),
// the same value is returned. Otherwise, if the receiver is an intermediary type,
// such as a folder, then the child value should be returned.
// Ex: fooFolder.leafCat() => foo.
leafCat() categorizer
// rootCat returns the root category for the categorizer
rootCat() categorizer
// unknownType returns the unknown category value
unknownCat() categorizer
// 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
}
// categoryT is the generic type interface of a categorizer
categoryT interface {
~string
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]filters.Filter
// 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
// setDefaults populates default values for certain scope categories.
// Primarily to ensure that root- or mid-tier scopes (such as folders)
// cascade 'Any' matching to more granular categories.
setDefaults()
}
// scopeT is the generic type interface of a scoper.
scopeT interface {
~map[string]filters.Filter
scoper
}
)
// makeScope produces a well formatted, typed scope that ensures all base values are populated.
func makeScope[T scopeT](
granularity string,
cat categorizer,
resources, vs []string,
) T {
s := T{
scopeKeyCategory: filters.Identity(cat.String()),
scopeKeyDataType: filters.Identity(cat.leafCat().String()),
scopeKeyGranularity: filters.Identity(granularity),
scopeKeyResource: filters.Identity(join(resources...)),
cat.String(): filterize(vs...),
cat.rootCat().String(): filterize(resources...),
}
return s
}
// makeFilterScope produces a well formatted, typed scope, with properties specifically oriented
// towards identifying filter-type scopes, that ensures all base values are populated.
func makeFilterScope[T scopeT](
cat, filterCat categorizer,
vs []string,
f func([]string) filters.Filter,
) T {
return T{
scopeKeyCategory: filters.Identity(cat.String()),
scopeKeyDataType: filters.Identity(cat.leafCat().String()),
scopeKeyGranularity: filters.Identity(Filter),
scopeKeyInfoFilter: filters.Identity(filterCat.String()),
scopeKeyResource: filters.Identity(Filter),
filterCat.String(): f(clean(vs)),
}
}
// ---------------------------------------------------------------------------
// scope funcs
// ---------------------------------------------------------------------------
// matches returns true if the category is included in the scope's
// data type, and the target string is included in the scope.
func matches[T scopeT, C categoryT](s T, cat C, target string) bool {
if !typeAndCategoryMatches(cat, s.categorizer()) {
return false
}
if len(target) == 0 {
return false
}
return s[cat.String()].Compare(target)
}
// getCategory returns the scope's category value.
// if s is a filter-type scope, returns the filter category.
func getCategory[T scopeT](s T) string {
return s[scopeKeyCategory].Target
}
// getFilterCategory returns the scope's infoFilter category value.
func getFilterCategory[T scopeT](s T) string {
return s[scopeKeyInfoFilter].Target
}
// getGranularity returns the scope's granularity value.
func getGranularity[T scopeT](s T) string {
return s[scopeKeyGranularity].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.Target)
}
// set sets a value by category to the scope. Only intended for internal
// use, not for exporting to callers.
func set[T scopeT](s T, cat categorizer, v []string) T {
s[cat.String()] = filterize(v...)
return s
}
// granularity describes the granularity (directory || item)
// of the data in scope.
func granularity[T scopeT](s T) string {
return s[scopeKeyGranularity].Target
}
// returns true if the category is included in the scope's category type,
// and the value is set to Any().
func isAnyTarget[T scopeT, C categoryT](s T, cat C) bool {
if !typeAndCategoryMatches(cat, s.categorizer()) {
return false
}
return s[cat.String()].Target == AnyTgt
}
// reduce filters the entries in the details to only those that match the
// inclusions, filters, and exclusions in the selector.
//
func reduce[T scopeT, C categoryT](
deets *details.Details,
s Selector,
dataCategories map[pathType]C,
) *details.Details {
if deets == nil {
return nil
}
// aggregate each scope type by category for easier isolation in future processing.
excls := scopesByCategory[T](s.Excludes, dataCategories)
filts := scopesByCategory[T](s.Filters, dataCategories)
incls := scopesByCategory[T](s.Includes, dataCategories)
ents := []details.DetailsEntry{}
// for each entry, compare that entry against the scopes of the same data type
for _, ent := range deets.Entries {
// todo: use Path pkg for this
repoPath := strings.Split(ent.RepoRef, "/")
dc, ok := dataCategories[pathTypeIn(repoPath)]
if !ok {
continue
}
passed := passes(
dc,
dc.pathValues(repoPath),
ent,
excls[dc],
filts[dc],
incls[dc],
)
if passed {
ents = append(ents, ent)
}
}
reduced := &details.Details{DetailsModel: deets.DetailsModel}
reduced.Entries = ents
return reduced
}
// TODO: this is a hack. We don't want these values declared here- it will get
// unwieldy to have all of them for all services. They should be declared in
// paths, since that's where service- and data-type-specific assertions are owned.
type pathType int
const (
unknownPathType pathType = iota
exchangeEventPath
exchangeContactPath
exchangeMailPath
)
// return the service data type of the path.
// TODO: this is a hack. We don't want this identification to occur in this
// package. It should get handled in paths, since that's where service- and
// data-type-specific assertions are owned.
// Ideally, we'd use something like path.DataType() instead of this func.
func pathTypeIn(p []string) pathType {
// not all paths will be len=3. Most should be longer.
// This just protects us from panicing below.
if len(p) < 4 {
return unknownPathType
}
switch p[3] {
case path.EmailCategory.String():
return exchangeMailPath
case path.ContactsCategory.String():
return exchangeContactPath
case path.EventsCategory.String():
return exchangeEventPath
}
// fallback for unmigrated events and contacts paths
switch p[2] {
case path.EmailCategory.String():
return exchangeMailPath
case path.ContactsCategory.String():
return exchangeContactPath
case path.EventsCategory.String():
return exchangeEventPath
}
return unknownPathType
}
// groups each scope by its category of data (specified by the service-selector).
// ex: a slice containing the scopes [mail1, mail2, event1]
// would produce a map like { mail: [1, 2], event: [1] }
// so long as "mail" and "event" are contained in cats.
func scopesByCategory[T scopeT, C categoryT](
scopes []scope,
cats map[pathType]C,
) map[C][]T {
m := map[C][]T{}
for _, cat := range cats {
m[cat] = []T{}
}
for _, sc := range scopes {
for _, cat := range cats {
t := T(sc)
if typeAndCategoryMatches(cat, t.categorizer()) {
m[cat] = append(m[cat], t)
}
}
}
return m
}
// passes compares each path to the included and excluded exchange scopes. Returns true
// if the path is included, passes filters, and not excluded.
func passes[T scopeT](
cat categorizer,
pathValues map[categorizer]string,
entry details.DetailsEntry,
excs, filts, incs []T,
) bool {
// a passing match requires either a filter or an inclusion
if len(incs)+len(filts) == 0 {
return false
}
// skip this check if 0 inclusions were populated
// since filters act as the inclusion check in that case
if len(incs) > 0 {
// at least one inclusion must apply.
var included bool
for _, inc := range incs {
if inc.matchesEntry(cat, pathValues, entry) {
included = true
break
}
}
if !included {
return false
}
}
// all filters must pass
for _, filt := range filts {
if !filt.matchesEntry(cat, pathValues, entry) {
return false
}
}
// any matching exclusion means failure
for _, exc := range excs {
if exc.matchesEntry(cat, pathValues, entry) {
return false
}
}
return true
}
// matchesPathValues will check whether the pathValues have matching entries
// in the scope. The keys of the values to match against are identified by
// the categorizer.
// Standard expectations apply: None() or missing values always fail, Any()
// always succeeds.
func matchesPathValues[T scopeT, C categoryT](
sc T,
cat C,
pathValues map[categorizer]string,
) bool {
// if scope specifies a filter category,
// path checking is automatically skipped.
if len(getFilterCategory(sc)) > 0 {
return false
}
for _, c := range cat.pathKeys() {
scopeVals := getCatValue(sc, c)
// the scope must define the targets to match on
if len(scopeVals) == 0 {
return false
}
// None() fails all matches
if scopeVals[0] == NoneTgt {
return false
}
// the path must contain a value to match against
pathVal, ok := pathValues[c]
if !ok {
return false
}
// all parts of the scope must match
cc := c.(C)
if !isAnyTarget(sc, cc) {
if filters.NotContains(join(scopeVals...)).Compare(pathVal) {
return false
}
}
}
return true
}
// ---------------------------------------------------------------------------
// categorizer funcs
// ---------------------------------------------------------------------------
// categoryMatches returns true if:
// - neither type is 'unknown'
// - either type is the root type
// - the leaf types match
func categoryMatches[C categoryT](a, b C) bool {
u := a.unknownCat()
if a == u || b == u {
return false
}
r := a.rootCat()
if a == r || b == r {
return true
}
return a.leafCat() == b.leafCat()
}
// typeAndCategoryMatches returns true if:
// - both parameters are the same categoryT type
// - the category matches for both types
func typeAndCategoryMatches[C categoryT](a C, b categorizer) bool {
bb, ok := b.(C)
if !ok {
return false
}
return categoryMatches(a, bb)
}