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:
Keepers 2022-11-03 16:37:25 -06:00 committed by GitHub
parent d1ef68b900
commit 302c3f39dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 299 additions and 20 deletions

View File

@ -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 {

View File

@ -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")
})
}
}

View File

@ -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