From 146a42cc514a566f0621f5cceae46965ed61f352 Mon Sep 17 00:00:00 2001 From: "Steven L. Speek" Date: Mon, 17 Mar 2014 20:07:08 +0100 Subject: [PATCH] search: accept 'and', 'or', and parentheses in expressions 'and' has precendence over 'or'. both operators are left associative parenthesized expressions are evaluated first Parser refactored, parseAtom split up. Change-Id: I1f194cc75df49bad9d30d041d689d8ba833076f1 --- pkg/search/expr.go | 704 +++++++++++++------- pkg/search/expr_test.go | 1034 ++++++++++++++++++++++++++++-- website/content/docs/release/0.8 | 3 +- 3 files changed, 1437 insertions(+), 304 deletions(-) diff --git a/pkg/search/expr.go b/pkg/search/expr.go index 1686b6e42..b1224f214 100644 --- a/pkg/search/expr.go +++ b/pkg/search/expr.go @@ -46,10 +46,422 @@ var ( whRangeExpr = regexp.MustCompile(`^(\d{0,10})-(\d{0,10})$`) ) -// parseExpression parses a search expression (e.g. "tag:funny -// near:portland") and returns a SearchQuery for that search text. The -// Constraint field will always be set. The Limit and Sort may also be -// set. +var ( + errNoMatchingOpening = errors.New("No matching opening parenthesis") + errNoMatchingClosing = errors.New("No matching closing parenthesis") + errCannotStartBinaryOp = errors.New("Expression cannot start with a binary operator") + errExpectedAtom = errors.New("Expected an atom") +) + +func andConst(a, b *Constraint) *Constraint { + return &Constraint{ + Logical: &LogicalConstraint{ + Op: "and", + A: a, + B: b, + }, + } +} + +func orConst(a, b *Constraint) *Constraint { + return &Constraint{ + Logical: &LogicalConstraint{ + Op: "or", + A: a, + B: b, + }, + } +} + +func notConst(a *Constraint) *Constraint { + return &Constraint{ + Logical: &LogicalConstraint{ + Op: "not", + A: a, + }, + } +} + +func stripNot(tokens []string) (negated bool, rest []string) { + rest = tokens + for len(rest) > 0 { + if rest[0] != "-" { + return negated, rest + } else { + negated = !negated + rest = rest[1:] + } + } + return +} + +func parseExp(ctx *context.Context, tokens []string) (c *Constraint, rest []string, err error) { + if len(tokens) == 0 { + return + } + rest = tokens + c, rest, err = parseOperand(ctx, rest) + if err != nil { + return + } + for len(rest) > 0 { + switch rest[0] { + case "and": + c, rest, err = parseConjunction(ctx, c, rest[1:]) + if err != nil { + return + } + continue + case "or": + return parseDisjunction(ctx, c, rest[1:]) + case ")": + return + } + c, rest, err = parseConjunction(ctx, c, rest) + if err != nil { + return + } + } + return +} + +func parseGroup(ctx *context.Context, tokens []string) (c *Constraint, rest []string, err error) { + rest = tokens + if rest[0] == "(" { + c, rest, err = parseExp(ctx, rest[1:]) + if err != nil { + return + } + if len(rest) > 0 && rest[0] == ")" { + rest = rest[1:] + } else { + err = errNoMatchingClosing + return + } + } else { + err = errNoMatchingOpening + return + } + return +} + +func parseDisjunction(ctx *context.Context, lhs *Constraint, tokens []string) (c *Constraint, rest []string, err error) { + var rhs *Constraint + c = lhs + rest = tokens + for { + rhs, rest, err = parseEntireConjunction(ctx, rest) + if err != nil { + return + } + c = orConst(c, rhs) + if len(rest) > 0 { + switch rest[0] { + case "or": + rest = rest[1:] + continue + case "and", ")": + return + } + return + } else { + return + } + } + return +} + +func parseEntireConjunction(ctx *context.Context, tokens []string) (c *Constraint, rest []string, err error) { + rest = tokens + for { + c, rest, err = parseOperand(ctx, rest) + if err != nil { + return + } + if len(rest) > 0 { + switch rest[0] { + case "and": + return parseConjunction(ctx, c, rest[1:]) + case ")", "or": + return + } + return parseConjunction(ctx, c, rest) + } else { + return + } + } + return +} + +func parseConjunction(ctx *context.Context, lhs *Constraint, tokens []string) (c *Constraint, rest []string, err error) { + var rhs *Constraint + c = lhs + rest = tokens + for { + rhs, rest, err = parseOperand(ctx, rest) + if err != nil { + return + } + c = andConst(c, rhs) + if len(rest) > 0 { + switch rest[0] { + case "or", ")": + return + case "and": + rest = rest[1:] + continue + } + } else { + return + } + } + return +} + +func parseOperand(ctx *context.Context, tokens []string) (c *Constraint, rest []string, err error) { + var negated bool + negated, rest = stripNot(tokens) + if len(rest) > 0 { + if rest[0] == "(" { + c, rest, err = parseGroup(ctx, rest) + if err != nil { + return + } + } else { + switch rest[0] { + case "and", "or": + err = errCannotStartBinaryOp + return + case ")": + err = errNoMatchingOpening + return + } + c, err = parseAtom(ctx, rest[0]) + if err != nil { + return + } + rest = rest[1:] + } + } else { + return nil, nil, errExpectedAtom + } + if negated { + c = notConst(c) + } + return +} + +func permOfFile(fc *FileConstraint) *Constraint { + return &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{File: fc}, + }, + } +} + +func whRatio(fc *FloatConstraint) *Constraint { + return permOfFile(&FileConstraint{ + IsImage: true, + WHRatio: fc, + }) +} + +func parseImageAtom(ctx *context.Context, word string) (*Constraint, error) { + if word == "is:image" { + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + File: &FileConstraint{ + IsImage: true, + }, + }, + }, + } + return c, nil + } + if word == "is:landscape" { + return whRatio(&FloatConstraint{Min: 1.0}), nil + } + if word == "is:portrait" { + return whRatio(&FloatConstraint{Max: 1.0}), nil + } + if word == "is:pano" { + return whRatio(&FloatConstraint{Min: 2.0}), nil + } + if strings.HasPrefix(word, "width:") { + m := whRangeExpr.FindStringSubmatch(strings.TrimPrefix(word, "width:")) + if m == nil { + return nil, errors.New("bogus width range") + } + c := permOfFile(&FileConstraint{ + IsImage: true, + Width: whIntConstraint(m[1], m[2]), + }) + return c, nil + } + if strings.HasPrefix(word, "height:") { + m := whRangeExpr.FindStringSubmatch(strings.TrimPrefix(word, "height:")) + if m == nil { + return nil, errors.New("bogus height range") + } + c := permOfFile(&FileConstraint{ + IsImage: true, + Height: whIntConstraint(m[1], m[2]), + }) + return c, nil + } + return nil, errors.New(fmt.Sprintf("Not an image-atom: %v", word)) +} + +func parseCoreAtom(ctx *context.Context, word string) (*Constraint, error) { + if m := tagExpr.FindStringSubmatch(word); m != nil { + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "tag", + SkipHidden: true, + Value: m[1], + }, + } + return c, nil + } + if m := titleExpr.FindStringSubmatch(word); m != nil { + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "title", + SkipHidden: true, + ValueMatches: &StringConstraint{ + Contains: m[1], + CaseInsensitive: true, + }, + }, + } + return c, nil + } + if m := attrExpr.FindStringSubmatch(word); m != nil { + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: m[1], + SkipHidden: true, + Value: m[2], + }, + } + return c, nil + } + if m := childrenOfExpr.FindStringSubmatch(word); m != nil { + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Relation: &RelationConstraint{ + Relation: "parent", + Any: &Constraint{ + BlobRefPrefix: m[1], + }, + }, + }, + } + return c, nil + } + if strings.HasPrefix(word, "before:") || strings.HasPrefix(word, "after:") { + before := false + when := "" + if strings.HasPrefix(word, "before:") { + before = true + when = strings.TrimPrefix(word, "before:") + } else { + when = strings.TrimPrefix(word, "after:") + } + base := "0000-01-01T00:00:00Z" + if len(when) < len(base) { + when += base[len(when):] + } + t, err := time.Parse(time.RFC3339, when) + if err != nil { + return nil, err + } + tc := &TimeConstraint{} + if before { + tc.Before = types.Time3339(t) + } else { + tc.After = types.Time3339(t) + } + c := &Constraint{ + Permanode: &PermanodeConstraint{ + Time: tc, + }, + } + return c, nil + } + if strings.HasPrefix(word, "format:") { + c := permOfFile(&FileConstraint{ + MIMEType: &StringConstraint{ + Equals: mimeFromFormat(strings.TrimPrefix(word, "format:")), + }, + }) + return c, nil + } + return nil, errors.New(fmt.Sprintf("Not an core-atom: %v", word)) +} + +func parseLocationAtom(ctx *context.Context, word string) (*Constraint, error) { + if strings.HasPrefix(word, "loc:") { + where := strings.TrimPrefix(word, "loc:") + rects, err := geocode.Lookup(ctx, where) + if err != nil { + return nil, err + } + if len(rects) == 0 { + return nil, fmt.Errorf("No location found for %q", where) + } + var locConstraint *Constraint + for i, rect := range rects { + rectConstraint := permOfFile(&FileConstraint{ + IsImage: true, + Location: &LocationConstraint{ + West: rect.SouthWest.Long, + East: rect.NorthEast.Long, + North: rect.NorthEast.Lat, + South: rect.SouthWest.Lat, + }, + }) + if i == 0 { + locConstraint = rectConstraint + } else { + locConstraint = orConst(locConstraint, rectConstraint) + } + } + return locConstraint, nil + } + if word == "has:location" { + c := permOfFile(&FileConstraint{ + IsImage: true, + Location: &LocationConstraint{ + Any: true, + }, + }) + return c, nil + } + + return nil, errors.New(fmt.Sprintf("Not an location-atom: %v", word)) +} + +func parseAtom(ctx *context.Context, word string) (*Constraint, error) { + c, err := parseCoreAtom(ctx, word) + if err == nil { + return c, nil + } + c, err = parseImageAtom(ctx, word) + if err == nil { + return c, nil + } + c, err = parseLocationAtom(ctx, word) + if err == nil { + return c, nil + } + log.Printf("Unknown search expression word %q", word) + return nil, errors.New(fmt.Sprintf("Unknown search atom: %s", word)) +} + func parseExpression(ctx *context.Context, exp string) (*SearchQuery, error) { base := &Constraint{ Permanode: &PermanodeConstraint{ @@ -65,234 +477,17 @@ func parseExpression(ctx *context.Context, exp string) (*SearchQuery, error) { return sq, nil } - andNot := false // whether the next and(x) is really a and(!x) - and := func(c *Constraint) { - old := sq.Constraint - if andNot { - c = &Constraint{ - Logical: &LogicalConstraint{ - Op: "not", - A: c, - }, - } - } - sq.Constraint = &Constraint{ - Logical: &LogicalConstraint{ - Op: "and", - A: old, - B: c, - }, - } - } - permOfFile := func(fc *FileConstraint) *Constraint { - return &Constraint{ - Permanode: &PermanodeConstraint{ - Attr: "camliContent", - ValueInSet: &Constraint{File: fc}, - }, - } - } - orConst := func(a, b *Constraint) *Constraint { - return &Constraint{ - Logical: &LogicalConstraint{ - Op: "or", - A: a, - B: b, - }, - } - } - andFile := func(fc *FileConstraint) { - and(permOfFile(fc)) - } - andWHRatio := func(fc *FloatConstraint) { - andFile(&FileConstraint{ - IsImage: true, - WHRatio: fc, - }) - } - words := splitExpr(exp) - for _, word := range words { - andNot = false - if strings.HasPrefix(word, "-") { - andNot = true - word = word[1:] - } - if m := tagExpr.FindStringSubmatch(word); m != nil { - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Attr: "tag", - SkipHidden: true, - Value: m[1], - }, - }) - continue - } - if m := titleExpr.FindStringSubmatch(word); m != nil { - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Attr: "title", - SkipHidden: true, - ValueMatches: &StringConstraint{ - Contains: m[1], - CaseInsensitive: true, - }, - }, - }) - continue - } - if word == "is:image" { - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Attr: "camliContent", - ValueInSet: &Constraint{ - File: &FileConstraint{ - IsImage: true, - }, - }, - }, - }) - continue - } - if word == "is:landscape" { - andWHRatio(&FloatConstraint{Min: 1.0}) - continue - } - if word == "is:portrait" { - andWHRatio(&FloatConstraint{Max: 1.0}) - continue - } - if word == "is:pano" { - andWHRatio(&FloatConstraint{Min: 2.0}) - continue - } - if word == "has:location" { - andFile(&FileConstraint{ - IsImage: true, - Location: &LocationConstraint{ - Any: true, - }, - }) - continue - } - if strings.HasPrefix(word, "format:") { - andFile(&FileConstraint{ - MIMEType: &StringConstraint{ - Equals: mimeFromFormat(strings.TrimPrefix(word, "format:")), - }, - }) - continue - } - if strings.HasPrefix(word, "width:") { - m := whRangeExpr.FindStringSubmatch(strings.TrimPrefix(word, "width:")) - if m == nil { - return nil, errors.New("bogus width range") - } - andFile(&FileConstraint{ - IsImage: true, - Width: whIntConstraint(m[1], m[2]), - }) - continue - } - if strings.HasPrefix(word, "height:") { - m := whRangeExpr.FindStringSubmatch(strings.TrimPrefix(word, "height:")) - if m == nil { - return nil, errors.New("bogus height range") - } - andFile(&FileConstraint{ - IsImage: true, - Height: whIntConstraint(m[1], m[2]), - }) - continue - } - if strings.HasPrefix(word, "before:") || strings.HasPrefix(word, "after:") { - before := false - when := "" - if strings.HasPrefix(word, "before:") { - before = true - when = strings.TrimPrefix(word, "before:") - } else { - when = strings.TrimPrefix(word, "after:") - } - base := "0000-01-01T00:00:00Z" - if len(when) < len(base) { - when += base[len(when):] - } - t, err := time.Parse(time.RFC3339, when) - if err != nil { - return nil, err - } - tc := &TimeConstraint{} - if before { - tc.Before = types.Time3339(t) - } else { - tc.After = types.Time3339(t) - } - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Time: tc, - }, - }) - continue - } - if strings.HasPrefix(word, "loc:") { - where := strings.TrimPrefix(word, "loc:") - rects, err := geocode.Lookup(ctx, where) - if err != nil { - return nil, err - } - if len(rects) == 0 { - return nil, fmt.Errorf("No location found for %q", where) - } - var locConstraint *Constraint - for i, rect := range rects { - rectConstraint := permOfFile(&FileConstraint{ - IsImage: true, - Location: &LocationConstraint{ - West: rect.SouthWest.Long, - East: rect.NorthEast.Long, - North: rect.NorthEast.Lat, - South: rect.SouthWest.Lat, - }, - }) - if i == 0 { - locConstraint = rectConstraint - } else { - locConstraint = orConst(locConstraint, rectConstraint) - } - } - and(locConstraint) - continue - } - if m := attrExpr.FindStringSubmatch(word); m != nil { - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Attr: m[1], - SkipHidden: true, - Value: m[2], - }, - }) - continue - } - if m := childrenOfExpr.FindStringSubmatch(word); m != nil { - and(&Constraint{ - Permanode: &PermanodeConstraint{ - Relation: &RelationConstraint{ - Relation: "parent", - Any: &Constraint{ - BlobRefPrefix: m[1], - }, - }, - }, - }) - continue - - } - log.Printf("Unknown search expression word %q", word) - // TODO: finish. better tokenization. non-operator tokens - // are text searches, etc. + c, rem, err := parseExp(ctx, words) + if err != nil { + return nil, err + } + if c != nil { + sq.Constraint = andConst(base, c) + } + if len(rem) > 0 { + return nil, errors.New("Trailing terms") } - return sq, nil } @@ -338,6 +533,8 @@ func mimeFromFormat(v string) string { // literal // foo: (for operators) // "quoted string" +// "(" +// ")" // " " (for any amount of space) // "-" negative sign func tokenizeExpr(exp string) []string { @@ -351,9 +548,31 @@ func tokenizeExpr(exp string) []string { } func firstToken(s string) (token, rest string) { + isWordBound := func(r byte) bool { + if isSpace(r) { + return true + } + switch r { + case '(', ')', '-': + return true + } + return false + } if s[0] == '-' { return "-", s[1:] } + if s[0] == '(' { + return "(", s[1:] + } + if s[0] == ')' { + return ")", s[1:] + } + if strings.HasPrefix(s, "and") && len(s) > 3 && isWordBound(s[3]) { + return "and", s[3:] + } + if strings.HasPrefix(s, "or") && len(s) > 2 && isWordBound(s[2]) { + return "or", s[2:] + } if isSpace(s[0]) { for len(s) > 0 && isSpace(s[0]) { s = s[1:] @@ -380,6 +599,12 @@ func firstToken(s string) (token, rest string) { if r == ':' { return s[:i+1], s[i+1:] } + if r == '(' { + return s[:i], s[i:] + } + if r == ')' { + return s[:i], s[i:] + } if r < utf8.RuneSelf && isSpace(byte(r)) { return s[:i], s[i:] } @@ -413,16 +638,21 @@ func splitExpr(exp string) []string { } } - // Split on space tokens and concatenate all the other tokens. + // Split on space, ), ( tokens and concatenate tokens ending with : // Not particularly efficient, though. var f []string - for i, token := range tokens { - if i == 0 { - f = append(f, token) - } else if token == " " { - f = append(f, "") - } else { + var nextPasted bool + for _, token := range tokens { + if token == " " { + continue + } else if nextPasted { f[len(f)-1] += token + nextPasted = false + } else { + f = append(f, token) + } + if strings.HasSuffix(token, ":") { + nextPasted = true } } return f diff --git a/pkg/search/expr_test.go b/pkg/search/expr_test.go index 833b446ac..256e4d1db 100644 --- a/pkg/search/expr_test.go +++ b/pkg/search/expr_test.go @@ -25,6 +25,419 @@ import ( "camlistore.org/pkg/context" ) +var skiphiddenC = &Constraint{ + Permanode: &PermanodeConstraint{ + SkipHidden: true, + }, +} + +var ispanoC = &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + File: &FileConstraint{ + IsImage: true, + WHRatio: &FloatConstraint{ + Min: 2.0, + }, + }, + }, + }, +} + +var attrfoobarC = &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "foo", + Value: "bar", + SkipHidden: true, + }, +} + +var attrgorunC = &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "go", + Value: "run", + SkipHidden: true, + }, +} + +var parseImageAtomTests = []struct { + name string + in string + want *Constraint + errContains string +}{ + { + in: "is:pano", + want: ispanoC, + }, + + { + in: "height:0-640", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + File: &FileConstraint{ + IsImage: true, + Height: &IntConstraint{ + ZeroMin: true, + Max: 640, + }, + }, + }, + }, + }, + }, + + { + in: "width:0-640", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + File: &FileConstraint{ + IsImage: true, + Width: &IntConstraint{ + ZeroMin: true, + Max: 640, + }, + }, + }, + }, + }, + }, +} + +func TestParseImageAtom(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseImageAtomTests { + in := tt.in + got, err := parseAtom(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("%v: parseImageAtom(%q) error: %v", tt.name, in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%v: parseImageAtom(%q) succeeded; want error containing %q", tt.name, in, tt.errContains) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%v: parseImageAtom(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseLocationAtomTests = []struct { + name string + in string + want *Constraint + errContains string +}{ + { + in: "has:location", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + + File: &FileConstraint{ + IsImage: true, + Location: &LocationConstraint{ + Any: true, + }, + }, + }, + }, + }, + }, +} + +func TestParseLocationAtom(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseLocationAtomTests { + in := tt.in + got, err := parseAtom(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("%v: parseLocationAtom(%q) error: %v", tt.name, in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%v: parseLocationAtom(%q) succeeded; want error containing %q", tt.name, in, tt.errContains) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%v: parseLocationAtom(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseCoreAtomTests = []struct { + name string + in string + want *Constraint + errContains string +}{ + { + name: "tag with spaces", + in: `tag:Foo Bar`, + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "tag", + Value: "Foo Bar", + SkipHidden: true, + }, + }, + }, + + { + name: "attribute search", + in: "attr:foo:bar", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "foo", + Value: "bar", + SkipHidden: true, + }, + }, + }, + + { + name: "attribute search with space in value", + in: `attr:foo:fun bar`, + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "foo", + Value: "fun bar", + SkipHidden: true, + }, + }, + }, + + { + in: "tag:funny", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "tag", + Value: "funny", + SkipHidden: true, + }, + }, + }, + + { + in: "title:Doggies", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "title", + ValueMatches: &StringConstraint{ + Contains: "Doggies", + CaseInsensitive: true, + }, + SkipHidden: true, + }, + }, + }, + + { + in: "childrenof:sha1-f00ba4", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Relation: &RelationConstraint{ + Relation: "parent", + Any: &Constraint{ + BlobRefPrefix: "sha1-f00ba4", + }, + }, + }, + }, + }, +} + +func TestParseCoreAtom(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseCoreAtomTests { + in := tt.in + got, err := parseAtom(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("%v: parseCoreAtom(%q) error: %v", tt.name, in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%v: parseCoreAtom(%q) succeeded; want error containing %q", tt.name, in, tt.errContains) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%v: parseCoreAtom(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseAtomTests = []struct { + name string + in string + want *Constraint + errContains string +}{ + { + in: "is:pano", + want: ispanoC, + }, + + { + in: "faulty:predicate", + errContains: "atom", + }, + + { + in: "width:0-640", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "camliContent", + ValueInSet: &Constraint{ + File: &FileConstraint{ + IsImage: true, + Width: &IntConstraint{ + ZeroMin: true, + Max: 640, + }, + }, + }, + }, + }, + }, + + { + name: "tag with spaces", + in: `tag:Foo Bar`, + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "tag", + Value: "Foo Bar", + SkipHidden: true, + }, + }, + }, + + { + name: "attribute search", + in: "attr:foo:bar", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "foo", + Value: "bar", + SkipHidden: true, + }, + }, + }, + + { + name: "attribute search with space in value", + in: `attr:foo:fun bar`, + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "foo", + Value: "fun bar", + SkipHidden: true, + }, + }, + }, + + { + in: "tag:funny", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "tag", + Value: "funny", + SkipHidden: true, + }, + }, + }, + + { + in: "title:Doggies", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Attr: "title", + ValueMatches: &StringConstraint{ + Contains: "Doggies", + CaseInsensitive: true, + }, + SkipHidden: true, + }, + }, + }, + + { + in: "childrenof:sha1-f00ba4", + want: &Constraint{ + Permanode: &PermanodeConstraint{ + Relation: &RelationConstraint{ + Relation: "parent", + Any: &Constraint{ + BlobRefPrefix: "sha1-f00ba4", + }, + }, + }, + }, + }, +} + +func TestParseAtom(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseAtomTests { + in := tt.in + got, err := parseAtom(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("%v: parseAtom(%q) error: %v", tt.name, in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%v: parseAtom(%q) succeeded; want error containing %q", tt.name, in, tt.errContains) + continue + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%v: parseAtom(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + var parseExprTests = []struct { name string in string @@ -36,40 +449,14 @@ var parseExprTests = []struct { name: "empty search", inList: []string{"", " ", "\n"}, want: &SearchQuery{ - Constraint: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + Constraint: skiphiddenC, }, }, { in: "is:pano", want: &SearchQuery{ - Constraint: &Constraint{ - Logical: &LogicalConstraint{ - Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, - B: &Constraint{ - Permanode: &PermanodeConstraint{ - Attr: "camliContent", - ValueInSet: &Constraint{ - File: &FileConstraint{ - IsImage: true, - WHRatio: &FloatConstraint{ - Min: 2.0, - }, - }, - }, - }, - }, - }, - }, + Constraint: andConst(skiphiddenC, ispanoC), }, }, @@ -79,11 +466,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "camliContent", @@ -110,11 +493,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "tag", @@ -134,11 +513,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "foo", @@ -158,11 +533,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "foo", @@ -181,11 +552,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "tag", @@ -204,11 +571,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Attr: "title", @@ -230,11 +593,7 @@ var parseExprTests = []struct { Constraint: &Constraint{ Logical: &LogicalConstraint{ Op: "and", - A: &Constraint{ - Permanode: &PermanodeConstraint{ - SkipHidden: true, - }, - }, + A: skiphiddenC, B: &Constraint{ Permanode: &PermanodeConstraint{ Relation: &RelationConstraint{ @@ -278,20 +637,530 @@ func TestParseExpression(t *testing.T) { if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { continue } - t.Errorf("parseExpression(%q) error: %v", in, err) + t.Errorf("%s: parseExpression(%q) error: %v", tt.name, in, err) continue } if tt.errContains != "" { - t.Errorf("parseExpression(%q) succeeded; want error containing %q", in, tt.errContains) + t.Errorf("%s: parseExpression(%q) succeeded; want error containing %q", tt.name, in, tt.errContains) continue } if !reflect.DeepEqual(got, tt.want) { - t.Errorf("parseExpression(%q) got:\n%s\n\nwant:%s\n", in, qj(got), qj(tt.want)) + t.Errorf("%s: parseExpression(%q) got:\n%s\n\nwant:%s\n", tt.name, in, qj(got), qj(tt.want)) } } } } +var parseDisjunctionTests = []struct { + name string + left int + tokens []string + lhs *Constraint + want *Constraint + remCount int + errContains string +}{ + { + name: "stop on )", + tokens: []string{"is:pano", ")"}, + want: orConst(nil, ispanoC), + remCount: 1, + }, + + { + tokens: []string{"is:pano", "and", "attr:foo:bar"}, + want: orConst(nil, andConst(ispanoC, attrfoobarC)), + remCount: 0, + }, + + { + name: "add atom", + tokens: []string{"is:pano"}, + want: orConst(nil, ispanoC), + remCount: 0, + }, +} + +func TestParseDisjunction(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseDisjunctionTests { + in := tt.tokens + got, rem, err := parseDisjunction(context.TODO(), tt.lhs, in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("parseDisjunction(%q) error: %v", in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%s: parseDisjunction(%q) succeeded; want error containing %q got: %s", tt.name, in, tt.errContains, cj(got)) + continue + } + if len(rem) != tt.remCount { + t.Errorf("%s: parseGroup(%q): expected remainder of length %d got %d (remainder: %s)\n", tt.name, in, tt.remCount, len(rem), rem) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: parseDisjunction(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseConjunctionTests = []struct { + name string + left int + tokens []string + lhs *Constraint + want *Constraint + remCount int + errContains string +}{ + { + name: "stop on )", + tokens: []string{"is:pano", ")"}, + want: andConst(nil, ispanoC), + remCount: 1, + }, + + { + name: "stop on or", + tokens: []string{"is:pano", "or"}, + want: andConst(nil, ispanoC), + remCount: 1, + }, + + { + name: "add atom", + tokens: []string{"is:pano"}, + want: andConst(nil, ispanoC), + remCount: 0, + }, +} + +func TestParseConjuction(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseConjunctionTests { + in := tt.tokens + got, rem, err := parseConjunction(context.TODO(), tt.lhs, in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("parseConjunction(%q) error: %v", in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%s: parseConjunction(%q) succeeded; want error containing %q got: %s", tt.name, in, tt.errContains, cj(got)) + continue + } + if len(rem) != tt.remCount { + t.Errorf("%s: parseGroup(%q): expected remainder of length %d got %d (remainder: %s)\n", tt.name, in, tt.remCount, len(rem), rem) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: parseConjunction(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseGroupTests = []struct { + name string + left int + tokens []string + want *Constraint + remCount int + errContains string +}{ + { + name: "simple grouped atom", + tokens: []string{"(", "is:pano", ")"}, + want: ispanoC, + remCount: 0, + }, + + { + name: "simple grouped or with remainder", + tokens: []string{"(", "attr:foo:bar", "or", "is:pano", ")", "attr:foo:bar"}, + want: orConst(attrfoobarC, ispanoC), + remCount: 1, + }, + + { + name: "simple grouped and with remainder", + tokens: []string{"(", "attr:foo:bar", "is:pano", ")", "attr:foo:bar"}, + want: andConst(attrfoobarC, ispanoC), + remCount: 1, + }, + + { + name: "simple grouped atom with remainder", + tokens: []string{"(", "is:pano", ")", "attr:foo:bar"}, + want: ispanoC, + remCount: 1, + }, +} + +func TestParseGroup(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseGroupTests { + in := tt.tokens + got, rem, err := parseGroup(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("parseGroup(%q) error: %v", in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%s: parseGroup(%q) succeeded; want error containing %q got: %s", tt.name, in, tt.errContains, cj(got)) + continue + } + if len(rem) != tt.remCount { + t.Errorf("%s: parseGroup(%q): expected remainder of length %d got %d (remainder: %s)\n", tt.name, in, tt.remCount, len(rem), rem) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: parseGroup(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseOperandTests = []struct { + name string + left int + tokens []string + want *Constraint + remCount int + errContains string +}{ + { + name: "group of one atom", + tokens: []string{"(", "is:pano", ")"}, + want: ispanoC, + remCount: 0, + }, + + { + name: "one atom", + tokens: []string{"is:pano"}, + want: ispanoC, + remCount: 0, + }, + + { + name: "two atoms", + tokens: []string{"is:pano", "attr:foo:bar"}, + want: ispanoC, + remCount: 1, + }, + + { + name: "grouped atom and atom", + tokens: []string{"(", "is:pano", ")", "attr:foo:bar"}, + want: ispanoC, + remCount: 1, + }, + + { + name: "atom and )", + tokens: []string{"is:pano", ")"}, + want: ispanoC, + remCount: 1, + }, +} + +func TestParseOperand(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseOperandTests { + in := tt.tokens + got, rem, err := parseOperand(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("parseOperand(%q) error: %v", in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%s: parseOperand(%q) succeeded; want error containing %q got: %s", tt.name, in, tt.errContains, cj(got)) + continue + } + if len(rem) != tt.remCount { + t.Errorf("%s: parseGroup(%q): expected remainder of length %d got %d (remainder: %s)\n", tt.name, in, tt.remCount, len(rem), rem) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: parseOperand(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + +var parseTests = []struct { + name string + left int + tokens []string + want *Constraint + remCount int + errContains string +}{ + { + name: "Unmatched (", + tokens: []string{"("}, + errContains: "No matching closing parenthesis", + }, + + { + name: "Unmatched )", + tokens: []string{")"}, + errContains: "No matching opening parenthesis", + }, + + { + name: "Unmatched ) at the end ", + tokens: []string{"is:pano", "or", "attr:foo:bar", ")"}, + want: orConst(ispanoC, attrfoobarC), + remCount: 1, + }, + + { + name: "empty search", + tokens: []string{}, + want: nil, + }, + + { + name: "faulty negation in 'or'", + tokens: []string{"is:pano", "-", "or", "-", "is:pano"}, + errContains: "Expression cannot start with a binary operator", + }, + + { + name: "faulty negation in 'or'", + tokens: []string{"is:pano", "or", "-"}, + errContains: "an atom", + }, + + { + name: "faulty disjunction, empty right", + tokens: []string{"is:pano", "or"}, + errContains: "an atom", + }, + + { + name: "faulty disjunction", + tokens: []string{"or", "is:pano"}, + errContains: "Expression cannot start with a binary operator", + }, + + { + name: "faulty conjunction", + tokens: []string{"and", "is:pano"}, + errContains: "Expression cannot start with a binary operator", + }, + + { + name: "one atom", + tokens: []string{"is:pano"}, + want: ispanoC, + }, + + { + name: "negated atom", + tokens: []string{"-", "is:pano"}, + want: notConst(ispanoC), + }, + + { + name: "double negated atom", + tokens: []string{"-", "-", "is:pano"}, + want: ispanoC, + }, + + { + name: "parenthesized atom with implicit 'and' and other atom", + tokens: []string{"(", "is:pano", ")", "attr:foo:bar"}, + want: andConst(ispanoC, attrfoobarC), + }, + + { + name: "negated implicit 'and'", + tokens: []string{"-", "(", "is:pano", "attr:foo:bar", ")"}, + want: notConst(andConst(ispanoC, attrfoobarC)), + }, + + { + name: "negated implicit 'and' with trailing attr:go:run", + tokens: []string{"-", "(", "is:pano", "attr:foo:bar", ")", "attr:go:run"}, + want: andConst(notConst(andConst(ispanoC, attrfoobarC)), attrgorunC), + }, + + { + name: "parenthesized implicit 'and'", + tokens: []string{"(", "is:pano", "attr:foo:bar", ")"}, + want: andConst(ispanoC, attrfoobarC), + }, + + { + name: "simple 'or' of two atoms", + tokens: []string{"is:pano", "or", "attr:foo:bar"}, + want: orConst(ispanoC, attrfoobarC), + }, + + { + name: "left associativity of implicit 'and'", + tokens: []string{"is:pano", "attr:go:run", "attr:foo:bar"}, + want: andConst(andConst(ispanoC, attrgorunC), attrfoobarC), + }, + + { + name: "left associativity of explicit 'and'", + tokens: []string{"is:pano", "and", "attr:go:run", "and", "attr:foo:bar"}, + want: andConst(andConst(ispanoC, attrgorunC), attrfoobarC), + }, + + { + name: "left associativity of 'or'", + tokens: []string{"is:pano", "or", "attr:go:run", "or", "attr:foo:bar"}, + want: orConst(orConst(ispanoC, attrgorunC), attrfoobarC)}, + + { + name: "left associativity of 'or' with negated atom", + tokens: []string{"is:pano", "or", "-", "attr:go:run", "or", "attr:foo:bar"}, + want: orConst(orConst(ispanoC, notConst(attrgorunC)), attrfoobarC), + }, + + { + name: "left associativity of 'or' with double negated atom", + tokens: []string{"is:pano", "or", "-", "-", "attr:go:run", "or", "attr:foo:bar"}, + want: orConst(orConst(ispanoC, attrgorunC), attrfoobarC), + }, + + { + name: "left associativity of 'or' with parenthesized subexpression", + tokens: []string{"is:pano", "or", "(", "-", "attr:go:run", ")", "or", "attr:foo:bar"}, + want: orConst(orConst(ispanoC, notConst(attrgorunC)), attrfoobarC), + }, + + { + name: "explicit 'and' of two atoms", + tokens: []string{"is:pano", "and", "attr:foo:bar"}, + want: andConst(ispanoC, attrfoobarC), + }, + + { + name: "implicit 'and' of two atom", + tokens: []string{"is:pano", "attr:foo:bar"}, + want: andConst(ispanoC, attrfoobarC), + }, + + { + name: "grouping an 'and' in an 'or'", + tokens: []string{"is:pano", "or", "(", "attr:foo:bar", "attr:go:run", ")"}, + want: orConst(ispanoC, andConst(attrfoobarC, attrgorunC)), + }, + + { + name: "precedence of 'and' over 'or'", + tokens: []string{"is:pano", "or", "attr:foo:bar", "and", "attr:go:run"}, + want: orConst(ispanoC, andConst(attrfoobarC, attrgorunC)), + }, + + { + name: "precedence of 'and' over 'or' with 'and' on the left", + tokens: []string{"is:pano", "and", "attr:foo:bar", "or", "attr:go:run"}, + want: orConst(andConst(ispanoC, attrfoobarC), attrgorunC), + }, + + { + name: "precedence of 'and' over 'or' with 'and' on the left and right", + tokens: []string{"is:pano", "and", "attr:foo:bar", "or", "attr:go:run", "is:pano"}, + want: orConst(andConst(ispanoC, attrfoobarC), andConst(attrgorunC, ispanoC)), + }, + + { + name: "precedence of 'and' over 'or' with 'and' on the left and right with a negation", + tokens: []string{"is:pano", "and", "attr:foo:bar", "or", "-", "attr:go:run", "is:pano"}, + want: orConst(andConst(ispanoC, attrfoobarC), andConst(notConst(attrgorunC), ispanoC)), + }, + + { + name: "precedence of 'and' over 'or' with 'and' on the left and right with a negation of group and trailing 'and'", + tokens: []string{"is:pano", "and", "attr:foo:bar", "or", "-", "(", "attr:go:run", "is:pano", ")", "is:pano"}, + want: orConst(andConst(ispanoC, attrfoobarC), andConst(notConst(andConst(attrgorunC, ispanoC)), ispanoC)), + }, + + { + name: "complicated", + tokens: []string{"-", "(", "is:pano", "and", "attr:foo:bar", ")", "or", "-", "(", "attr:go:run", "is:pano", ")", "is:pano"}, + want: orConst(notConst(andConst(ispanoC, attrfoobarC)), andConst(notConst(andConst(attrgorunC, ispanoC)), ispanoC)), + }, + + { + name: "complicated", + tokens: []string{"is:pano", "or", "attr:foo:bar", "attr:go:run", "or", "-", "attr:go:run", "or", "is:pano", "is:pano"}, + want: orConst(orConst(orConst(ispanoC, andConst(attrfoobarC, attrgorunC)), notConst(attrgorunC)), andConst(ispanoC, ispanoC)), + }, + + { + name: "complicated", + tokens: []string{"is:pano", "or", "attr:foo:bar", "attr:go:run", "or", "-", "attr:go:run", "or", "is:pano", "is:pano", "or", "attr:foo:bar"}, + want: orConst(orConst(orConst(orConst(ispanoC, andConst(attrfoobarC, attrgorunC)), notConst(attrgorunC)), andConst(ispanoC, ispanoC)), attrfoobarC), + }, +} + +func TestParse(t *testing.T) { + cj := func(c *Constraint) []byte { + v, err := json.MarshalIndent(c, "", " ") + if err != nil { + panic(err) + } + return v + } + for _, tt := range parseTests { + in := tt.tokens + got, rem, err := parseExp(context.TODO(), in) + if err != nil { + if tt.errContains != "" && strings.Contains(err.Error(), tt.errContains) { + continue + } + t.Errorf("parse(%q) error: %v", in, err) + continue + } + if tt.errContains != "" { + t.Errorf("%s: parse(%q) succeeded; want error containing %q got: %s", tt.name, in, tt.errContains, cj(got)) + continue + } + if len(rem) != tt.remCount { + t.Errorf("%s: parseGroup(%q): expected remainder of length %d got %d (remainder: %s)\n", tt.name, in, tt.remCount, len(rem), rem) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("%s: parse(%q) got:\n%s\n\nwant:%s\n", tt.name, in, cj(got), cj(tt.want)) + } + } +} + func TestSplitExpr(t *testing.T) { tests := []struct { in string @@ -303,6 +1172,8 @@ func TestSplitExpr(t *testing.T) { {" foo bar ", []string{"foo", "bar"}}, {`foo:"quoted string" bar`, []string{`foo:quoted string`, "bar"}}, {`foo:"quoted \"-containing"`, []string{`foo:quoted "-containing`}}, + {"foo:bar:foo or bar or (foo or bar)", []string{"foo:bar:foo", "or", "bar", "or", "(", "foo", "or", "bar", ")"}}, + {"-foo:bar:foo", []string{"-", "foo:bar:foo"}}, } for _, tt := range tests { got := splitExpr(tt.in) @@ -319,12 +1190,20 @@ func TestTokenizeExpr(t *testing.T) { }{ {"", nil}, {"foo", []string{"foo"}}, + {"andouille and android", []string{"andouille", " ", "and", " ", "android"}}, + {"and(", []string{"and", "("}}, + {"oregon", []string{"oregon"}}, + {"or-", []string{"or", "-"}}, + {")or-", []string{")", "or", "-"}}, {"foo bar", []string{"foo", " ", "bar"}}, {" foo bar ", []string{" ", "foo", " ", "bar", " "}}, {" -foo bar", []string{" ", "-", "foo", " ", "bar"}}, {`-"quote"foo`, []string{"-", `"quote"`, "foo"}}, {`foo:"quoted string" bar`, []string{"foo:", `"quoted string"`, " ", "bar"}}, {`"quoted \"-containing"`, []string{`"quoted \"-containing"`}}, + {"foo and bar or foobar", []string{"foo", " ", "and", " ", "bar", " ", "or", " ", "foobar"}}, + {"(foo:bar and bar) or foobar", []string{"(", "foo:", "bar", " ", "and", " ", "bar", ")", " ", "or", " ", "foobar"}}, + {"(foo:bar:foo and bar) or foobar", []string{"(", "foo:", "bar:", "foo", " ", "and", " ", "bar", ")", " ", "or", " ", "foobar"}}, } for _, tt := range tests { got := tokenizeExpr(tt.in) @@ -333,3 +1212,26 @@ func TestTokenizeExpr(t *testing.T) { } } } + +func TestStripNot(t *testing.T) { + tests := []struct { + in []string + wantNeg bool + wantRest []string + }{ + {[]string{"-", "-", "foo"}, false, []string{"foo"}}, + {[]string{"-", "-", "("}, false, []string{"("}}, + {[]string{"-", "("}, true, []string{"("}}, + {[]string{"foo"}, false, []string{"foo"}}, + {[]string{"-", "-", "-", "foo"}, true, []string{"foo"}}, + } + for _, tt := range tests { + gotNeg, gotRest := stripNot(tt.in) + if !reflect.DeepEqual(gotNeg, tt.wantNeg) { + t.Errorf("stripNot(%s) = %v; want %v", tt.in, gotNeg, tt.wantNeg) + } + if !reflect.DeepEqual(gotRest, tt.wantRest) { + t.Errorf("stripNot(%s) = %v; want %v", tt.in, gotRest, tt.wantRest) + } + } +} diff --git a/website/content/docs/release/0.8 b/website/content/docs/release/0.8 index 1f676304f..bc7f1cef0 100644 --- a/website/content/docs/release/0.8 +++ b/website/content/docs/release/0.8 @@ -33,7 +33,8 @@ Or browse at Github: g
  • Indexer now gracefully handles dependent blobs arriving out of order and reschedules indexing as dependencies are satisified. This means full syncs in arbitrary orders don't confuse the indexer.
  • RelationConstraint implemented for Relation type "parent"
  • Search operator syntax for searching permanodes for arbitrary attributes: attr:<attribute_name>:<attribute_value>
  • -
  • Search operator syntax for searching permanodes by their parent permanode(s): childrenof:sha1-xxxxx +
  • Search operator syntax for searching permanodes by their parent permanode(s): childrenof:sha1-xxxxx
  • +
  • Searches can contain parenthesized subexpressions and accept 'and' and 'or'. A whitespace separation still means and.
  • Permanode deletions now taken into account by index corpus, hence in search results too.
  • Importers