add path prefix/contains filters (#1425)
## Description Adds two specialized filters: path prefix and path contains. These filters differ from prior additions in two ways: 1/ they accept a slice of inputs and perform an implicit any-match when compared, 2/ they have particular rules about matching completely within delimited elements. In the next PR, these filters will replace the standard prefix and contains comparisons that are currently used in folder selection. ## Type of change - [x] 🌻 Feature ## Issue(s) * #1224 ## Test Plan - [x] 💚 E2E
This commit is contained in:
parent
d1ef68b900
commit
302c3f39dd
@ -2,6 +2,8 @@ package filters
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/alcionai/corso/src/pkg/path"
|
||||
)
|
||||
|
||||
type comparator int
|
||||
@ -24,21 +26,46 @@ const (
|
||||
Fails
|
||||
// passthrough for the target
|
||||
IdentityValue
|
||||
// "foo" is a prefix of "foo/bar/baz"
|
||||
// "foo" is a prefix of "foobarbaz"
|
||||
TargetPrefixes
|
||||
// "foo" equals any complete element prefix of "foo/bar/baz"
|
||||
TargetPathPrefix
|
||||
// "foo" equals any complete element in "foo/bar/baz"
|
||||
TargetPathContains
|
||||
)
|
||||
|
||||
func norm(s string) string {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// normPathElem ensures the string is:
|
||||
// 1. prefixed with a single path.pathSeparator (ex: `/`)
|
||||
// 2. suffixed with a single path.pathSeparator (ex: `/`)
|
||||
// This is done to facilitate future regex comparisons
|
||||
// without re-running the prefix-suffix addition multiple
|
||||
// times per target.
|
||||
func normPathElem(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
if s[0] != path.PathSeparator {
|
||||
s = string(path.PathSeparator) + s
|
||||
}
|
||||
|
||||
s = path.TrimTrailingSlash(s) + string(path.PathSeparator)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Filter contains a comparator func and the target to
|
||||
// compare values against. Filter.Matches(v) returns
|
||||
// true if Filter.Comparer(filter.target, v) is true.
|
||||
type Filter struct {
|
||||
Comparator comparator `json:"comparator"`
|
||||
Target string `json:"target"` // the value to compare against
|
||||
Negate bool `json:"negate"` // when true, negate the comparator result
|
||||
Target string `json:"target"` // the value to compare against
|
||||
Targets []string `json:"targets"` // the set of values to compare against, always with "any-match" behavior
|
||||
Negate bool `json:"negate"` // when true, negate the comparator result
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
@ -133,9 +160,102 @@ func NotPrefix(target string) Filter {
|
||||
return newFilter(TargetPrefixes, target, true)
|
||||
}
|
||||
|
||||
// PathPrefix creates a filter where Compare(v) is true if
|
||||
// target.Prefix(v) &&
|
||||
// split(target)[i].Equals(split(v)[i]) for _all_ i in 0..len(target)-1
|
||||
// ex: target "/foo/bar" returns true for input "/foo/bar/baz",
|
||||
// but false for "/foo/barbaz"
|
||||
//
|
||||
// Unlike single-target filters, this filter accepts a
|
||||
// slice of targets, will compare an input against each target
|
||||
// independently, and returns true if one or more of the
|
||||
// comparisons succeed.
|
||||
func PathPrefix(targets []string) Filter {
|
||||
tgts := make([]string, len(targets))
|
||||
for i := range targets {
|
||||
tgts[i] = normPathElem(targets[i])
|
||||
}
|
||||
|
||||
return newSliceFilter(TargetPathPrefix, tgts, false)
|
||||
}
|
||||
|
||||
// NotPathPrefix creates a filter where Compare(v) is true if
|
||||
// !target.Prefix(v) ||
|
||||
// !split(target)[i].Equals(split(v)[i]) for _any_ i in 0..len(target)-1
|
||||
// ex: target "/foo/bar" returns false for input "/foo/bar/baz",
|
||||
// but true for "/foo/barbaz"
|
||||
//
|
||||
// Unlike single-target filters, this filter accepts a
|
||||
// slice of targets, will compare an input against each target
|
||||
// independently, and returns true if one or more of the
|
||||
// comparisons succeed.
|
||||
func NotPathPrefix(targets []string) Filter {
|
||||
tgts := make([]string, len(targets))
|
||||
for i := range targets {
|
||||
tgts[i] = normPathElem(targets[i])
|
||||
}
|
||||
|
||||
return newSliceFilter(TargetPathPrefix, tgts, true)
|
||||
}
|
||||
|
||||
// PathContains creates a filter where Compare(v) is true if
|
||||
// for _any_ elem e in split(v), target.Equals(e) ||
|
||||
// for _any_ sequence of elems in split(v), target.Equals(path.Join(e[n:m]))
|
||||
// ex: target "foo" returns true for input "/baz/foo/bar",
|
||||
// but false for "/baz/foobar"
|
||||
// ex: target "baz/foo" returns true for input "/baz/foo/bar",
|
||||
// but false for "/baz/foobar"
|
||||
//
|
||||
// Unlike single-target filters, this filter accepts a
|
||||
// slice of targets, will compare an input against each target
|
||||
// independently, and returns true if one or more of the
|
||||
// comparisons succeed.
|
||||
func PathContains(targets []string) Filter {
|
||||
tgts := make([]string, len(targets))
|
||||
for i := range targets {
|
||||
tgts[i] = normPathElem(targets[i])
|
||||
}
|
||||
|
||||
return newSliceFilter(TargetPathContains, tgts, false)
|
||||
}
|
||||
|
||||
// NotPathContains creates a filter where Compare(v) is true if
|
||||
// for _every_ elem e in split(v), !target.Equals(e) ||
|
||||
// for _every_ sequence of elems in split(v), !target.Equals(path.Join(e[n:m]))
|
||||
// ex: target "foo" returns false for input "/baz/foo/bar",
|
||||
// but true for "/baz/foobar"
|
||||
// ex: target "baz/foo" returns false for input "/baz/foo/bar",
|
||||
// but true for "/baz/foobar"
|
||||
//
|
||||
// Unlike single-target filters, this filter accepts a
|
||||
// slice of targets, will compare an input against each target
|
||||
// independently, and returns true if one or more of the
|
||||
// comparisons succeed.
|
||||
func NotPathContains(targets []string) Filter {
|
||||
tgts := make([]string, len(targets))
|
||||
for i := range targets {
|
||||
tgts[i] = normPathElem(targets[i])
|
||||
}
|
||||
|
||||
return newSliceFilter(TargetPathContains, tgts, true)
|
||||
}
|
||||
|
||||
// newFilter is the standard filter constructor.
|
||||
func newFilter(c comparator, target string, negate bool) Filter {
|
||||
return Filter{c, target, negate}
|
||||
return Filter{
|
||||
Comparator: c,
|
||||
Target: target,
|
||||
Negate: negate,
|
||||
}
|
||||
}
|
||||
|
||||
// newSliceFilter constructs filters that contain multiple targets
|
||||
func newSliceFilter(c comparator, targets []string, negate bool) Filter {
|
||||
return Filter{
|
||||
Comparator: c,
|
||||
Targets: targets,
|
||||
Negate: negate,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
@ -144,6 +264,12 @@ func newFilter(c comparator, target string, negate bool) Filter {
|
||||
|
||||
// CompareAny checks whether any one of all the provided
|
||||
// inputs passes the filter.
|
||||
//
|
||||
// Note that, as a gotcha, CompareAny can resolve truthily
|
||||
// for both the standard and negated versions of a filter.
|
||||
// Ex: consider the input CompareAny(true, false), which
|
||||
// will return true for both Equals(true) and NotEquals(true),
|
||||
// because at least one element matches for both filters.
|
||||
func (f Filter) CompareAny(inputs ...string) bool {
|
||||
for _, in := range inputs {
|
||||
if f.Compare(in) {
|
||||
@ -156,7 +282,10 @@ func (f Filter) CompareAny(inputs ...string) bool {
|
||||
|
||||
// Compare checks whether the input passes the filter.
|
||||
func (f Filter) Compare(input string) bool {
|
||||
var cmp func(string, string) bool
|
||||
var (
|
||||
cmp func(string, string) bool
|
||||
hasSlice bool
|
||||
)
|
||||
|
||||
switch f.Comparator {
|
||||
case EqualTo, IdentityValue:
|
||||
@ -171,18 +300,36 @@ func (f Filter) Compare(input string) bool {
|
||||
cmp = in
|
||||
case TargetPrefixes:
|
||||
cmp = prefixed
|
||||
case TargetPathPrefix:
|
||||
cmp = pathPrefix
|
||||
hasSlice = true
|
||||
case TargetPathContains:
|
||||
cmp = pathContains
|
||||
hasSlice = true
|
||||
case Passes:
|
||||
return true
|
||||
case Fails:
|
||||
return false
|
||||
}
|
||||
|
||||
result := cmp(norm(f.Target), norm(input))
|
||||
if f.Negate {
|
||||
result = !result
|
||||
targets := []string{f.Target}
|
||||
if hasSlice {
|
||||
targets = f.Targets
|
||||
}
|
||||
|
||||
return result
|
||||
for _, tgt := range targets {
|
||||
success := cmp(norm(tgt), norm(input))
|
||||
if f.Negate {
|
||||
success = !success
|
||||
}
|
||||
|
||||
// any-match
|
||||
if success {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// true if t == i
|
||||
@ -215,18 +362,49 @@ func prefixed(target, input string) bool {
|
||||
return strings.HasPrefix(input, target)
|
||||
}
|
||||
|
||||
// true if target is an _element complete_ prefix match
|
||||
// on the input. Element complete means we do not
|
||||
// succeed on partial element matches (ex: "/foo" does
|
||||
// not match "/foobar").
|
||||
//
|
||||
// As a precondition, assumes the target value has been
|
||||
// passed through normPathElem().
|
||||
//
|
||||
// The input is assumed to be the complete path that may
|
||||
// have the target as a prefix.
|
||||
func pathPrefix(target, input string) bool {
|
||||
return strings.HasPrefix(normPathElem(input), target)
|
||||
}
|
||||
|
||||
// true if target has an _element complete_ equality
|
||||
// with any element, or any sequence of elements, from
|
||||
// the input. Element complete means we do not succeed
|
||||
// on partial element matches (ex: foo does not match
|
||||
// /foobar, and foo/bar does not match foo/barbaz).
|
||||
//
|
||||
// As a precondition, assumes the target value has been
|
||||
// passed through normPathElem().
|
||||
//
|
||||
// Input is assumed to be the complete path that may
|
||||
// contain the target as an element or sequence of elems.
|
||||
func pathContains(target, input string) bool {
|
||||
return strings.Contains(normPathElem(input), target)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
|
||||
// prefixString maps the comparators to string prefixes for printing.
|
||||
var prefixString = map[comparator]string{
|
||||
EqualTo: "eq:",
|
||||
GreaterThan: "gt:",
|
||||
LessThan: "lt:",
|
||||
TargetContains: "cont:",
|
||||
TargetIn: "in:",
|
||||
TargetPrefixes: "pr:",
|
||||
EqualTo: "eq:",
|
||||
GreaterThan: "gt:",
|
||||
LessThan: "lt:",
|
||||
TargetContains: "cont:",
|
||||
TargetIn: "in:",
|
||||
TargetPrefixes: "pfx:",
|
||||
TargetPathPrefix: "pathPfx:",
|
||||
TargetPathContains: "pathCont:",
|
||||
}
|
||||
|
||||
func (f Filter) String() string {
|
||||
|
||||
@ -205,3 +205,104 @@ func (suite *FiltersSuite) TestPrefixes() {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *FiltersSuite) TestPathPrefix() {
|
||||
table := []struct {
|
||||
name string
|
||||
targets []string
|
||||
input string
|
||||
expectF assert.BoolAssertionFunc
|
||||
expectNF assert.BoolAssertionFunc
|
||||
}{
|
||||
{"Exact - same case", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - different case", []string{"fa"}, "/fA", assert.True, assert.False},
|
||||
{"Prefix - same case", []string{"fA"}, "/fA/fB", assert.True, assert.False},
|
||||
{"Prefix - different case", []string{"fa"}, "/fA/fB", assert.True, assert.False},
|
||||
{"Exact - multiple folders", []string{"fA/fB"}, "/fA/fB", assert.True, assert.False},
|
||||
{"Prefix - single folder partial", []string{"f"}, "/fA/fB", assert.False, assert.True},
|
||||
{"Prefix - multi folder partial", []string{"fA/f"}, "/fA/fB", assert.False, assert.True},
|
||||
{"Target Longer - single folder", []string{"fA"}, "/f", assert.False, assert.True},
|
||||
{"Target Longer - multi folder", []string{"fA/fB"}, "/fA/f", assert.False, assert.True},
|
||||
{"Not prefix - single folder", []string{"fA"}, "/af", assert.False, assert.True},
|
||||
{"Not prefix - multi folder", []string{"fA/fB"}, "/fA/bf", assert.False, assert.True},
|
||||
{"Exact - target variations - none", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - prefix", []string{"/fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - suffix", []string{"fA/"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - both", []string{"/fA/"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - input variations - none", []string{"fA"}, "fA", assert.True, assert.False},
|
||||
{"Exact - input variations - prefix", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - input variations - suffix", []string{"fA"}, "fA/", assert.True, assert.False},
|
||||
{"Exact - input variations - both", []string{"fA"}, "/fA/", assert.True, assert.False},
|
||||
{"Prefix - target variations - none", []string{"fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Prefix - target variations - prefix", []string{"/fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Prefix - target variations - suffix", []string{"fA/"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Prefix - target variations - both", []string{"/fA/"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Prefix - input variations - none", []string{"fA"}, "fA/fb", assert.True, assert.False},
|
||||
{"Prefix - input variations - prefix", []string{"fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Prefix - input variations - suffix", []string{"fA"}, "fA/fb/", assert.True, assert.False},
|
||||
{"Prefix - input variations - both", []string{"fA"}, "/fA/fb/", assert.True, assert.False},
|
||||
{"Slice - one matches", []string{"foo", "fa/f", "fA"}, "/fA/fb", assert.True, assert.True},
|
||||
{"Slice - none match", []string{"foo", "fa/f", "f"}, "/fA/fb", assert.False, assert.True},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.T().Run(test.name, func(t *testing.T) {
|
||||
f := filters.PathPrefix(test.targets)
|
||||
nf := filters.NotPathPrefix(test.targets)
|
||||
|
||||
test.expectF(t, f.Compare(test.input), "filter")
|
||||
test.expectNF(t, nf.Compare(test.input), "negated filter")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *FiltersSuite) TestPathContains() {
|
||||
table := []struct {
|
||||
name string
|
||||
targets []string
|
||||
input string
|
||||
expectF assert.BoolAssertionFunc
|
||||
expectNF assert.BoolAssertionFunc
|
||||
}{
|
||||
{"Exact - same case", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - different case", []string{"fa"}, "/fA", assert.True, assert.False},
|
||||
{"Cont - same case single target", []string{"fA"}, "/Z/fA/B", assert.True, assert.False},
|
||||
{"Cont - different case single target", []string{"fA"}, "/z/fa/b", assert.True, assert.False},
|
||||
{"Cont - same case multi target", []string{"Z/fA"}, "/Z/fA/B", assert.True, assert.False},
|
||||
{"Cont - different case multi target", []string{"fA/B"}, "/z/fa/b", assert.True, assert.False},
|
||||
{"Exact - multiple folders", []string{"Z/fA/B"}, "/Z/fA/B", assert.True, assert.False},
|
||||
{"Cont - single folder partial", []string{"folder"}, "/Z/fA/fB", assert.False, assert.True},
|
||||
{"Cont - multi folder partial", []string{"fA/fold"}, "/Z/fA/fB", assert.False, assert.True},
|
||||
{"Target Longer - single folder", []string{"fA"}, "/folder", assert.False, assert.True},
|
||||
{"Target Longer - multi folder", []string{"fA/fB"}, "/fA/fold", assert.False, assert.True},
|
||||
{"Not cont - single folder", []string{"fA"}, "/afolder", assert.False, assert.True},
|
||||
{"Not cont - single target", []string{"fA"}, "/z/afolder/bfolder", assert.False, assert.True},
|
||||
{"Not cont - multi folder", []string{"fA/fB"}, "/z/fA/bfolder", assert.False, assert.True},
|
||||
{"Exact - target variations - none", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - prefix", []string{"/fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - suffix", []string{"fA/"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - target variations - both", []string{"/fA/"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - input variations - none", []string{"fA"}, "fA", assert.True, assert.False},
|
||||
{"Exact - input variations - prefix", []string{"fA"}, "/fA", assert.True, assert.False},
|
||||
{"Exact - input variations - suffix", []string{"fA"}, "fA/", assert.True, assert.False},
|
||||
{"Exact - input variations - both", []string{"fA"}, "/fA/", assert.True, assert.False},
|
||||
{"Cont - target variations - none", []string{"fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Cont - target variations - prefix", []string{"/fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Cont - target variations - suffix", []string{"fA/"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Cont - target variations - both", []string{"/fA/"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Cont - input variations - none", []string{"fA"}, "fA/fb", assert.True, assert.False},
|
||||
{"Cont - input variations - prefix", []string{"fA"}, "/fA/fb", assert.True, assert.False},
|
||||
{"Cont - input variations - suffix", []string{"fA"}, "fA/fb/", assert.True, assert.False},
|
||||
{"Cont - input variations - both", []string{"fA"}, "/fA/fb/", assert.True, assert.False},
|
||||
{"Slice - one matches", []string{"foo", "fa/f", "fA"}, "/fA/fb", assert.True, assert.True},
|
||||
{"Slice - none match", []string{"foo", "fa/f", "f"}, "/fA/fb", assert.False, assert.True},
|
||||
}
|
||||
for _, test := range table {
|
||||
suite.T().Run(test.name, func(t *testing.T) {
|
||||
f := filters.PathContains(test.targets)
|
||||
nf := filters.NotPathContains(test.targets)
|
||||
|
||||
test.expectF(t, f.Compare(test.input), "filter")
|
||||
test.expectNF(t, nf.Compare(test.input), "negated filter")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,13 +48,13 @@ const templateErrPathParsing = "parsing resource path from %s"
|
||||
|
||||
const (
|
||||
escapeCharacter = '\\'
|
||||
pathSeparator = '/'
|
||||
PathSeparator = '/'
|
||||
|
||||
shortRefCharacters = 12
|
||||
)
|
||||
|
||||
var charactersToEscape = map[rune]struct{}{
|
||||
pathSeparator: {},
|
||||
PathSeparator: {},
|
||||
escapeCharacter: {},
|
||||
}
|
||||
|
||||
@ -442,7 +442,7 @@ func validateEscapedElement(element string) error {
|
||||
// escaped. If there were no trailing path separator character(s) or the separator(s)
|
||||
// were escaped the input is returned unchanged.
|
||||
func TrimTrailingSlash(element string) string {
|
||||
for len(element) > 0 && element[len(element)-1] == pathSeparator {
|
||||
for len(element) > 0 && element[len(element)-1] == PathSeparator {
|
||||
lastIdx := len(element) - 1
|
||||
numSlashes := 0
|
||||
|
||||
@ -469,7 +469,7 @@ func TrimTrailingSlash(element string) string {
|
||||
func join(elements []string) string {
|
||||
// Have to use strings because path package does not handle escaped '/' and
|
||||
// '\' according to the escaping rules.
|
||||
return strings.Join(elements, string(pathSeparator))
|
||||
return strings.Join(elements, string(PathSeparator))
|
||||
}
|
||||
|
||||
// split takes an escaped string and returns a slice of path elements. The
|
||||
@ -490,7 +490,7 @@ func split(segment string) []string {
|
||||
continue
|
||||
}
|
||||
|
||||
if c != pathSeparator {
|
||||
if c != PathSeparator {
|
||||
prevWasSeparator = false
|
||||
numEscapes = 0
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user