diff --git a/src/cli/utils/exchange.go b/src/cli/utils/exchange.go index 5261f2640..d10b98b61 100644 --- a/src/cli/utils/exchange.go +++ b/src/cli/utils/exchange.go @@ -54,7 +54,7 @@ type ExchangeOpts struct { func AddExchangeInclude( sel *selectors.ExchangeRestore, resource, folders, items []string, - incl func([]string, []string, []string) []selectors.ExchangeScope, + eisc selectors.ExchangeItemScopeConstructor, ) { lf, li := len(folders), len(items) @@ -68,15 +68,19 @@ func AddExchangeInclude( resource = selectors.Any() } - if lf == 0 { - folders = selectors.Any() - } - if li == 0 { items = selectors.Any() } - sel.Include(incl(resource, folders, items)) + containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(folders) + + if len(containsFolders) > 0 { + sel.Include(eisc(resource, containsFolders, items)) + } + + if len(prefixFolders) > 0 { + sel.Include(eisc(resource, prefixFolders, items, selectors.PrefixMatch())) + } } // AddExchangeFilter adds the scope of the provided values to the selector's diff --git a/src/cli/utils/exchange_test.go b/src/cli/utils/exchange_test.go index c355e789e..7316051ad 100644 --- a/src/cli/utils/exchange_test.go +++ b/src/cli/utils/exchange_test.go @@ -19,7 +19,7 @@ func TestExchangeUtilsSuite(t *testing.T) { suite.Run(t, new(ExchangeUtilsSuite)) } -func (suite *ExchangeUtilsSuite) TestValidateBackupDetailFlags() { +func (suite *ExchangeUtilsSuite) TestValidateRestoreFlags() { table := []struct { name string backupID string @@ -56,7 +56,7 @@ func (suite *ExchangeUtilsSuite) TestValidateBackupDetailFlags() { } } -func (suite *ExchangeUtilsSuite) TestIncludeExchangeBackupDetailDataSelectors() { +func (suite *ExchangeUtilsSuite) TestIncludeExchangeRestoreDataSelectors() { stub := []string{"id-stub"} many := []string{"fnord", "smarf"} a := []string{utils.Wildcard} @@ -308,7 +308,76 @@ func (suite *ExchangeUtilsSuite) TestIncludeExchangeBackupDetailDataSelectors() } } -func (suite *ExchangeUtilsSuite) TestFilterExchangeBackupDetailInfoSelectors() { +func (suite *ExchangeUtilsSuite) TestAddExchangeInclude() { + var ( + empty = []string{} + single = []string{"single"} + multi = []string{"more", "than", "one"} + containsOnly = []string{"contains"} + prefixOnly = []string{"/prefix"} + containsAndPrefix = []string{"contains", "/prefix"} + eisc = selectors.NewExchangeRestore().Contacts // type independent, just need the func + ) + + table := []struct { + name string + resources, folders, items []string + expectIncludeLen int + }{ + { + name: "no inputs", + resources: empty, + folders: empty, + items: empty, + expectIncludeLen: 0, + }, + { + name: "single inputs", + resources: single, + folders: single, + items: single, + expectIncludeLen: 1, + }, + { + name: "multi inputs", + resources: multi, + folders: multi, + items: multi, + expectIncludeLen: 1, + }, + { + name: "folder contains", + resources: empty, + folders: containsOnly, + items: empty, + expectIncludeLen: 1, + }, + { + name: "folder prefixes", + resources: empty, + folders: prefixOnly, + items: empty, + expectIncludeLen: 1, + }, + { + name: "folder prefixes and contains", + resources: empty, + folders: containsAndPrefix, + items: empty, + expectIncludeLen: 2, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + sel := selectors.NewExchangeRestore() + // no return, mutates sel as a side effect + utils.AddExchangeInclude(sel, test.resources, test.folders, test.items, eisc) + assert.Len(t, sel.Includes, test.expectIncludeLen) + }) + } +} + +func (suite *ExchangeUtilsSuite) TestFilterExchangeRestoreInfoSelectors() { stub := "id-stub" table := []struct { diff --git a/src/cli/utils/onedrive.go b/src/cli/utils/onedrive.go index 5705d5d64..a668593f8 100644 --- a/src/cli/utils/onedrive.go +++ b/src/cli/utils/onedrive.go @@ -74,12 +74,18 @@ func IncludeOneDriveRestoreDataSelectors( sel *selectors.OneDriveRestore, opts OneDriveOpts, ) { + lp, ln := len(opts.Paths), len(opts.Names) + + // only use the inclusion if either a path or item name + // is specified + if lp+ln == 0 { + return + } + if len(opts.Users) == 0 { opts.Users = selectors.Any() } - lp, ln := len(opts.Paths), len(opts.Names) - // either scope the request to a set of users if lp+ln == 0 { sel.Include(sel.Users(opts.Users)) @@ -89,15 +95,19 @@ func IncludeOneDriveRestoreDataSelectors( opts.Paths = trimFolderSlash(opts.Paths) - if lp == 0 { - opts.Paths = selectors.Any() - } - if ln == 0 { opts.Names = selectors.Any() } - sel.Include(sel.Items(opts.Users, opts.Paths, opts.Names)) + containsFolders, prefixFolders := splitFoldersIntoContainsAndPrefix(opts.Paths) + + if len(containsFolders) > 0 { + sel.Include(sel.Items(opts.Users, containsFolders, opts.Names)) + } + + if len(prefixFolders) > 0 { + sel.Include(sel.Items(opts.Users, prefixFolders, opts.Names, selectors.PrefixMatch())) + } } // FilterOneDriveRestoreInfoSelectors builds the common info-selector filters. diff --git a/src/cli/utils/onedrive_test.go b/src/cli/utils/onedrive_test.go new file mode 100644 index 000000000..2866c1cb3 --- /dev/null +++ b/src/cli/utils/onedrive_test.go @@ -0,0 +1,90 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/selectors" +) + +func (suite *ExchangeUtilsSuite) TestIncludeOneDriveRestoreDataSelectors() { + var ( + empty = []string{} + single = []string{"single"} + multi = []string{"more", "than", "one"} + containsOnly = []string{"contains"} + prefixOnly = []string{"/prefix"} + containsAndPrefix = []string{"contains", "/prefix"} + ) + + table := []struct { + name string + opts utils.OneDriveOpts + expectIncludeLen int + }{ + { + name: "no inputs", + opts: utils.OneDriveOpts{ + Users: empty, + Paths: empty, + Names: empty, + }, + expectIncludeLen: 0, + }, + { + name: "single inputs", + opts: utils.OneDriveOpts{ + Users: single, + Paths: single, + Names: single, + }, + expectIncludeLen: 1, + }, + { + name: "multi inputs", + opts: utils.OneDriveOpts{ + Users: multi, + Paths: multi, + Names: multi, + }, + expectIncludeLen: 1, + }, + { + name: "folder contains", + opts: utils.OneDriveOpts{ + Users: empty, + Paths: containsOnly, + Names: empty, + }, + expectIncludeLen: 1, + }, + { + name: "folder prefixes", + opts: utils.OneDriveOpts{ + Users: empty, + Paths: prefixOnly, + Names: empty, + }, + expectIncludeLen: 1, + }, + { + name: "folder prefixes and contains", + opts: utils.OneDriveOpts{ + Users: empty, + Paths: containsAndPrefix, + Names: empty, + }, + expectIncludeLen: 2, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + sel := selectors.NewOneDriveRestore() + // no return, mutates sel as a side effect + utils.IncludeOneDriveRestoreDataSelectors(sel, test.opts) + assert.Len(t, sel.Includes, test.expectIncludeLen) + }) + } +} diff --git a/src/cli/utils/utils.go b/src/cli/utils/utils.go index 072b21484..305fcae6a 100644 --- a/src/cli/utils/utils.go +++ b/src/cli/utils/utils.go @@ -8,7 +8,9 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/alcionai/corso/src/pkg/path" "github.com/alcionai/corso/src/pkg/repository" + "github.com/alcionai/corso/src/pkg/selectors" ) // common flag names @@ -64,3 +66,34 @@ func AddCommand(parent, c *cobra.Command) (*cobra.Command, *pflag.FlagSet) { return c, c.Flags() } + +// separates the provided folders into two sets: folders that use a pathContains +// comparison (the default), and folders that use a pathPrefix comparison. +// Any element beginning with a path.PathSeparator (ie: '/') is moved to the prefix +// comparison set. If folders is nil, returns only containsFolders with the any matcher. +func splitFoldersIntoContainsAndPrefix(folders []string) ([]string, []string) { + var ( + containsFolders = []string{} + prefixFolders = []string{} + ) + + if len(folders) == 0 { + return selectors.Any(), nil + } + + // separate folder selection inputs by behavior. + // any input beginning with a '/' character acts as a prefix match. + for _, f := range folders { + if len(f) == 0 { + continue + } + + if f[0] == path.PathSeparator { + prefixFolders = append(prefixFolders, f) + } else { + containsFolders = append(containsFolders, f) + } + } + + return containsFolders, prefixFolders +} diff --git a/src/cli/utils/utils_test.go b/src/cli/utils/utils_test.go index b32c91a09..84f4cda41 100644 --- a/src/cli/utils/utils_test.go +++ b/src/cli/utils/utils_test.go @@ -1,4 +1,4 @@ -package utils_test +package utils import ( "testing" @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "github.com/alcionai/corso/src/cli/utils" + "github.com/alcionai/corso/src/pkg/selectors" ) type CliUtilsSuite struct { @@ -33,6 +33,52 @@ func (suite *CliUtilsSuite) TestRequireProps() { }, } for _, test := range table { - test.errCheck(suite.T(), utils.RequireProps(test.props)) + test.errCheck(suite.T(), RequireProps(test.props)) + } +} + +func (suite *CliUtilsSuite) TestSplitFoldersIntoContainsAndPrefix() { + table := []struct { + name string + input []string + expectC []string + expectP []string + }{ + { + name: "empty", + expectC: selectors.Any(), + expectP: nil, + }, + { + name: "only contains", + input: []string{"a", "b", "c"}, + expectC: []string{"a", "b", "c"}, + expectP: []string{}, + }, + { + name: "only leading slash counts as contains", + input: []string{"a/////", "\\/b", "\\//c\\/"}, + expectC: []string{"a/////", "\\/b", "\\//c\\/"}, + expectP: []string{}, + }, + { + name: "only prefix", + input: []string{"/a", "/b", "/\\/c"}, + expectC: []string{}, + expectP: []string{"/a", "/b", "/\\/c"}, + }, + { + name: "mixed", + input: []string{"/a", "b", "/c"}, + expectC: []string{"b"}, + expectP: []string{"/a", "/c"}, + }, + } + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + c, p := splitFoldersIntoContainsAndPrefix(test.input) + assert.ElementsMatch(t, test.expectC, c, "contains set") + assert.ElementsMatch(t, test.expectP, p, "prefix set") + }) } } diff --git a/src/internal/connector/exchange/service_functions_test.go b/src/internal/connector/exchange/service_functions_test.go index ee9d5fc5d..649f36163 100644 --- a/src/internal/connector/exchange/service_functions_test.go +++ b/src/internal/connector/exchange/service_functions_test.go @@ -277,7 +277,7 @@ func (suite *ServiceFunctionsIntegrationSuite) TestGetAllMailFolders() { getScope: func(t *testing.T) selectors.ExchangeScope { return selectors. NewExchangeBackup(). - MailFolders([]string{userID}, []string{nonExistantLookup})[0] + MailFolders([]string{userID}, []string{nonExistantLookup}, selectors.PrefixMatch())[0] }, }, } diff --git a/src/pkg/selectors/exchange.go b/src/pkg/selectors/exchange.go index 09609627c..442e8c1d5 100644 --- a/src/pkg/selectors/exchange.go +++ b/src/pkg/selectors/exchange.go @@ -166,6 +166,8 @@ func (s *exchange) DiscreteScopes(userPNs []string) []ExchangeScope { return discreteScopes[ExchangeScope](s.Selector, ExchangeUser, userPNs) } +type ExchangeItemScopeConstructor func([]string, []string, []string, ...option) []ExchangeScope + // ------------------- // Scope Factories @@ -173,13 +175,14 @@ func (s *exchange) DiscreteScopes(userPNs []string) []ExchangeScope { // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] -func (s *exchange) Contacts(users, folders, contacts []string) []ExchangeScope { +// options are only applied to the folder scopes. +func (s *exchange) Contacts(users, folders, contacts []string, opts ...option) []ExchangeScope { scopes := []ExchangeScope{} scopes = append( scopes, makeScope[ExchangeScope](ExchangeContact, users, contacts). - set(ExchangeContactFolder, folders), + set(ExchangeContactFolder, folders, opts...), ) return scopes @@ -189,6 +192,7 @@ func (s *exchange) Contacts(users, folders, contacts []string) []ExchangeScope { // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] +// options are only applied to the folder scopes. func (s *exchange) ContactFolders(users, folders []string, opts ...option) []ExchangeScope { var ( scopes = []ExchangeScope{} @@ -207,13 +211,14 @@ func (s *exchange) ContactFolders(users, folders []string, opts ...option) []Exc // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] -func (s *exchange) Events(users, calendars, events []string) []ExchangeScope { +// options are only applied to the folder scopes. +func (s *exchange) Events(users, calendars, events []string, opts ...option) []ExchangeScope { scopes := []ExchangeScope{} scopes = append( scopes, makeScope[ExchangeScope](ExchangeEvent, users, events). - set(ExchangeEventCalendar, calendars), + set(ExchangeEventCalendar, calendars, opts...), ) return scopes @@ -224,6 +229,7 @@ func (s *exchange) Events(users, calendars, events []string) []ExchangeScope { // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] +// options are only applied to the folder scopes. func (s *exchange) EventCalendars(users, events []string, opts ...option) []ExchangeScope { var ( scopes = []ExchangeScope{} @@ -242,13 +248,14 @@ func (s *exchange) EventCalendars(users, events []string, opts ...option) []Exch // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] -func (s *exchange) Mails(users, folders, mails []string) []ExchangeScope { +// options are only applied to the folder scopes. +func (s *exchange) Mails(users, folders, mails []string, opts ...option) []ExchangeScope { scopes := []ExchangeScope{} scopes = append( scopes, makeScope[ExchangeScope](ExchangeMail, users, mails). - set(ExchangeMailFolder, folders), + set(ExchangeMailFolder, folders, opts...), ) return scopes @@ -258,6 +265,7 @@ func (s *exchange) Mails(users, folders, mails []string) []ExchangeScope { // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] +// options are only applied to the folder scopes. func (s *exchange) MailFolders(users, folders []string, opts ...option) []ExchangeScope { var ( scopes = []ExchangeScope{} diff --git a/src/pkg/selectors/onedrive.go b/src/pkg/selectors/onedrive.go index c868bad27..02bd4f05a 100644 --- a/src/pkg/selectors/onedrive.go +++ b/src/pkg/selectors/onedrive.go @@ -179,6 +179,7 @@ func (s *oneDrive) Users(users []string) []OneDriveScope { // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] +// options are only applied to the folder scopes. func (s *oneDrive) Folders(users, folders []string, opts ...option) []OneDriveScope { var ( scopes = []OneDriveScope{} @@ -197,13 +198,14 @@ func (s *oneDrive) Folders(users, folders []string, opts ...option) []OneDriveSc // If any slice contains selectors.Any, that slice is reduced to [selectors.Any] // If any slice contains selectors.None, that slice is reduced to [selectors.None] // If any slice is empty, it defaults to [selectors.None] -func (s *oneDrive) Items(users, folders, items []string) []OneDriveScope { +// options are only applied to the folder scopes. +func (s *oneDrive) Items(users, folders, items []string, opts ...option) []OneDriveScope { scopes := []OneDriveScope{} scopes = append( scopes, makeScope[OneDriveScope](OneDriveItem, users, items). - set(OneDriveFolder, folders), + set(OneDriveFolder, folders, opts...), ) return scopes