diff --git a/like.go b/like.go new file mode 100644 index 000000000..db92232d7 --- /dev/null +++ b/like.go @@ -0,0 +1,159 @@ +package pilosa + +import ( + "strings" + "unicode/utf8" +) + +func tokenizeLike(like string) []string { + var tokens []string + for like != "" { + var token string + i := strings.IndexAny(like, "%_") + switch { + case i == 0: + j := 1 + for j < len(like) && (like[j] == '%' || like[j] == '_') { + j++ + } + token, like = like[:j], like[j:] + case i < 0: + token, like = like, "" + default: + token, like = like[:i], like[i:] + } + tokens = append(tokens, token) + } + return tokens +} + +type filterStepKind uint8 + +const ( + filterStepPrefix filterStepKind = iota + filterStepSkipN + filterStepSkipThrough + filterStepMinLength +) + +type filterStep struct { + kind filterStepKind + str string + n int +} + +func planLike(like string) []filterStep { + tokens := tokenizeLike(like) + + steps := make([]filterStep, 0, len(tokens)) + var merged bool + for i, t := range tokens { + if merged { + merged = false + continue + } + + var step filterStep + hasPercent := strings.ContainsRune(t, '%') + underscores := strings.Count(t, "_") + switch { + case hasPercent && i+1 < len(tokens): + step = filterStep{ + kind: filterStepSkipThrough, + str: tokens[i+1], + n: underscores, + } + merged = true + case hasPercent: + step = filterStep{ + kind: filterStepMinLength, + n: underscores, + } + case underscores > 0: + step = filterStep{ + kind: filterStepSkipN, + n: underscores, + } + default: + step = filterStep{ + kind: filterStepPrefix, + str: t, + } + } + steps = append(steps, step) + } + + return steps +} + +func matchLike(key string, like ...filterStep) bool { + for i, step := range like { + switch step.kind { + case filterStepPrefix: + if !strings.HasPrefix(key, step.str) { + return false + } + key = key[len(step.str):] + case filterStepSkipN: + n := step.n + for j := 0; j < n; j++ { + _, len := utf8.DecodeRuneInString(key) + if len == 0 { + return false + } + key = key[len:] + } + case filterStepSkipThrough: + var skipped int + for skipped < step.n { + j := strings.Index(key, step.str) + switch j { + case -1: + return false + case 0: + _, len := utf8.DecodeRuneInString(key) + if len == 0 { + return false + } + key = key[len:] + skipped += len + default: + k := -1 + for k = range key[:j] { + } + skipped += k + 1 + + key = key[j:] + } + } + + remaining := like[i+1:] + for { + j := strings.Index(key, step.str) + switch { + case j == -1: + return false + case j > 0: + key = key[j:] + } + + if matchLike(key[len(step.str):], remaining...) { + return true + } + key = key[1:] + } + case filterStepMinLength: + if len(key) < step.n { + return false + } + + j := -1 + for j = range key { + } + return j+1 >= step.n + default: + panic("invalid step") + } + } + return key == "" +} diff --git a/like_test.go b/like_test.go new file mode 100644 index 000000000..9ff6be91c --- /dev/null +++ b/like_test.go @@ -0,0 +1,184 @@ +package pilosa + +import ( + "reflect" + "testing" +) + +func TestPlanLike(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + like string + plan []filterStep + match, nonmatch []string + }{ + { + name: "Empty", + like: "", + plan: []filterStep{}, + match: []string{""}, + nonmatch: []string{"a", " "}, + }, + { + name: "Exact", + like: "x", + plan: []filterStep{ + { + kind: filterStepPrefix, + str: "x", + }, + }, + match: []string{"x"}, + nonmatch: []string{"", "y", "z", "xy", "yx"}, + }, + { + name: "Anything", + like: "%", + plan: []filterStep{ + { + kind: filterStepMinLength, + n: 0, + }, + }, + match: []string{"", "a", "b", "ab"}, + }, + { + name: "Prefix", + like: "x%", + plan: []filterStep{ + { + kind: filterStepPrefix, + str: "x", + }, + { + kind: filterStepMinLength, + n: 0, + }, + }, + match: []string{"xy", "xyz", "xyzzy"}, + nonmatch: []string{"plugh", "yx", ""}, + }, + { + name: "Suffix", + like: "%x", + plan: []filterStep{ + { + kind: filterStepSkipThrough, + str: "x", + }, + }, + match: []string{"x", "xx", "ax"}, + nonmatch: []string{"", "a", "x^"}, + }, + { + name: "Sandwich", + like: "x%y", + plan: []filterStep{ + { + kind: filterStepPrefix, + str: "x", + }, + { + kind: filterStepSkipThrough, + str: "y", + }, + }, + match: []string{"xy", "xzy", "xyzzy"}, + nonmatch: []string{"plugh", ".xy.", ".x.y", "x.y."}, + }, + { + name: "SingleRune", + like: "_", + plan: []filterStep{ + { + kind: filterStepSkipN, + n: 1, + }, + }, + match: []string{"a", "á", "☺"}, + nonmatch: []string{"ab", "á", "h̷"}, + }, + { + name: "DoubleRune", + like: "__", + plan: []filterStep{ + { + kind: filterStepSkipN, + n: 2, + }, + }, + match: []string{"ab", "á", "h̷"}, + nonmatch: []string{"a", "á", "☺", "abc"}, + }, + { + name: "MiddleBlank", + like: "x_y", + plan: []filterStep{ + { + kind: filterStepPrefix, + str: "x", + }, + { + kind: filterStepSkipN, + n: 1, + }, + { + kind: filterStepPrefix, + str: "y", + }, + }, + match: []string{"x.y", "xay", "x y", "x⊕y"}, + nonmatch: []string{"x++y", "", "a"}, + }, + { + name: "MinLength", + like: "_%_", + plan: []filterStep{ + { + kind: filterStepMinLength, + n: 2, + }, + }, + match: []string{"ab", "á", "abc", "pilosa"}, + nonmatch: []string{"h", "á", ".", "☺"}, + }, + } + t.Run("Plan", func(t *testing.T) { + t.Parallel() + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + plan := planLike(c.like) + if !reflect.DeepEqual(plan, c.plan) { + t.Errorf("incorrect plan: %v", plan) + } + }) + } + }) + t.Run("Match", func(t *testing.T) { + t.Parallel() + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + for _, m := range c.match { + if !matchLike(m, c.plan...) { + t.Errorf("key %q was not matched", m) + } + } + for _, nm := range c.nonmatch { + if matchLike(nm, c.plan...) { + t.Errorf("key %q was matched", nm) + } + } + }) + } + }) +}