Skip to content

Commit 2277d0a

Browse files
authored
Add search string parsing (stashapp#1982)
* Add search string parsing * Add manual page
1 parent 27c0fc8 commit 2277d0a

File tree

15 files changed

+503
-25
lines changed

15 files changed

+503
-25
lines changed

pkg/models/search.go

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package models
2+
3+
import "strings"
4+
5+
const (
6+
or = "OR"
7+
orSymbol = "|"
8+
notPrefix = '-'
9+
phraseChar = '"'
10+
)
11+
12+
// SearchSpecs provides the specifications for text-based searches.
13+
type SearchSpecs struct {
14+
// MustHave specifies all of the terms that must appear in the results.
15+
MustHave []string
16+
17+
// AnySets specifies sets of terms where one of each set must appear in the results.
18+
AnySets [][]string
19+
20+
// MustNot specifies all terms that must not appear in the results.
21+
MustNot []string
22+
}
23+
24+
// combinePhrases detects quote characters at the start and end of
25+
// words and combines the contents into a single word.
26+
func combinePhrases(words []string) []string {
27+
var ret []string
28+
startIndex := -1
29+
for i, w := range words {
30+
if startIndex == -1 {
31+
// looking for start of phrase
32+
// this could either be " or -"
33+
ww := w
34+
if len(w) > 0 && w[0] == notPrefix {
35+
ww = w[1:]
36+
}
37+
if len(ww) > 0 && ww[0] == phraseChar && (len(ww) < 2 || ww[len(ww)-1] != phraseChar) {
38+
startIndex = i
39+
continue
40+
}
41+
42+
ret = append(ret, w)
43+
} else if len(w) > 0 && w[len(w)-1] == phraseChar { // looking for end of phrase
44+
// combine words
45+
phrase := strings.Join(words[startIndex:i+1], " ")
46+
47+
// add to return value
48+
ret = append(ret, phrase)
49+
startIndex = -1
50+
}
51+
}
52+
53+
if startIndex != -1 {
54+
ret = append(ret, words[startIndex:]...)
55+
}
56+
57+
return ret
58+
}
59+
60+
func extractOrConditions(words []string, searchSpec *SearchSpecs) []string {
61+
for foundOr := true; foundOr; {
62+
foundOr = false
63+
for i, w := range words {
64+
if i > 0 && i < len(words)-1 && (strings.EqualFold(w, or) || w == orSymbol) {
65+
// found an OR keyword
66+
// first operand will be the last word
67+
startIndex := i - 1
68+
69+
// find the last operand
70+
// this will be the last word not preceded by OR
71+
lastIndex := len(words) - 1
72+
for ii := i + 2; ii < len(words); ii += 2 {
73+
if !strings.EqualFold(words[ii], or) {
74+
lastIndex = ii - 1
75+
break
76+
}
77+
}
78+
79+
foundOr = true
80+
81+
// combine the words into an any set
82+
var set []string
83+
for ii := startIndex; ii <= lastIndex; ii += 2 {
84+
word := extractPhrase(words[ii])
85+
if word == "" {
86+
continue
87+
}
88+
set = append(set, word)
89+
}
90+
91+
searchSpec.AnySets = append(searchSpec.AnySets, set)
92+
93+
// take out the OR'd words
94+
words = append(words[0:startIndex], words[lastIndex+1:]...)
95+
96+
// break and reparse
97+
break
98+
}
99+
}
100+
}
101+
102+
return words
103+
}
104+
105+
func extractNotConditions(words []string, searchSpec *SearchSpecs) []string {
106+
var ret []string
107+
108+
for _, w := range words {
109+
if len(w) > 1 && w[0] == notPrefix {
110+
word := extractPhrase(w[1:])
111+
if word == "" {
112+
continue
113+
}
114+
searchSpec.MustNot = append(searchSpec.MustNot, word)
115+
} else {
116+
ret = append(ret, w)
117+
}
118+
}
119+
120+
return ret
121+
}
122+
123+
func extractPhrase(w string) string {
124+
if len(w) > 1 && w[0] == phraseChar && w[len(w)-1] == phraseChar {
125+
return w[1 : len(w)-1]
126+
}
127+
128+
return w
129+
}
130+
131+
// ParseSearchString parses the Q value and returns a SearchSpecs object.
132+
//
133+
// By default, any words in the search value must appear in the results.
134+
// Words encompassed by quotes (") as treated as a single term.
135+
// Where keyword "OR" (case-insensitive) appears (and is not part of a quoted phrase), one of the
136+
// OR'd terms must appear in the results.
137+
// Where a keyword is prefixed with "-", that keyword must not appear in the results.
138+
// Where OR appears as the first or last term, or where one of the OR operands has a
139+
// not prefix, then the OR is treated literally.
140+
func ParseSearchString(s string) SearchSpecs {
141+
s = strings.TrimSpace(s)
142+
143+
if s == "" {
144+
return SearchSpecs{}
145+
}
146+
147+
// break into words
148+
words := strings.Split(s, " ")
149+
150+
// combine phrases first, then extract OR conditions, then extract NOT conditions
151+
// and the leftovers will be AND'd
152+
ret := SearchSpecs{}
153+
words = combinePhrases(words)
154+
words = extractOrConditions(words, &ret)
155+
words = extractNotConditions(words, &ret)
156+
157+
for _, w := range words {
158+
// ignore empty quotes
159+
word := extractPhrase(w)
160+
if word == "" {
161+
continue
162+
}
163+
ret.MustHave = append(ret.MustHave, word)
164+
}
165+
166+
return ret
167+
}

pkg/models/search_test.go

+227
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package models
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestParseSearchString(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
q string
12+
want SearchSpecs
13+
}{
14+
{
15+
"basic",
16+
"a b c",
17+
SearchSpecs{
18+
MustHave: []string{"a", "b", "c"},
19+
},
20+
},
21+
{
22+
"empty",
23+
"",
24+
SearchSpecs{},
25+
},
26+
{
27+
"whitespace",
28+
" ",
29+
SearchSpecs{},
30+
},
31+
{
32+
"single",
33+
"a",
34+
SearchSpecs{
35+
MustHave: []string{"a"},
36+
},
37+
},
38+
{
39+
"quoted",
40+
`"a b" c`,
41+
SearchSpecs{
42+
MustHave: []string{"a b", "c"},
43+
},
44+
},
45+
{
46+
"quoted double space",
47+
`"a b" c`,
48+
SearchSpecs{
49+
MustHave: []string{"a b", "c"},
50+
},
51+
},
52+
{
53+
"quoted end space",
54+
`"a b " c`,
55+
SearchSpecs{
56+
MustHave: []string{"a b ", "c"},
57+
},
58+
},
59+
{
60+
"no matching end quote",
61+
`"a b c`,
62+
SearchSpecs{
63+
MustHave: []string{`"a`, "b", "c"},
64+
},
65+
},
66+
{
67+
"no matching start quote",
68+
`a b c"`,
69+
SearchSpecs{
70+
MustHave: []string{"a", "b", `c"`},
71+
},
72+
},
73+
{
74+
"or",
75+
"a OR b",
76+
SearchSpecs{
77+
AnySets: [][]string{
78+
{"a", "b"},
79+
},
80+
},
81+
},
82+
{
83+
"multi or",
84+
"a OR b c OR d",
85+
SearchSpecs{
86+
AnySets: [][]string{
87+
{"a", "b"},
88+
{"c", "d"},
89+
},
90+
},
91+
},
92+
{
93+
"lowercase or",
94+
"a or b",
95+
SearchSpecs{
96+
AnySets: [][]string{
97+
{"a", "b"},
98+
},
99+
},
100+
},
101+
{
102+
"or symbol",
103+
"a | b",
104+
SearchSpecs{
105+
AnySets: [][]string{
106+
{"a", "b"},
107+
},
108+
},
109+
},
110+
{
111+
"quoted or",
112+
`a "OR" b`,
113+
SearchSpecs{
114+
MustHave: []string{"a", "OR", "b"},
115+
},
116+
},
117+
{
118+
"quoted or symbol",
119+
`a "|" b`,
120+
SearchSpecs{
121+
MustHave: []string{"a", "|", "b"},
122+
},
123+
},
124+
{
125+
"or phrases",
126+
`"a b" OR "c d"`,
127+
SearchSpecs{
128+
AnySets: [][]string{
129+
{"a b", "c d"},
130+
},
131+
},
132+
},
133+
{
134+
"or at start",
135+
"OR a",
136+
SearchSpecs{
137+
MustHave: []string{"OR", "a"},
138+
},
139+
},
140+
{
141+
"or at end",
142+
"a OR",
143+
SearchSpecs{
144+
MustHave: []string{"a", "OR"},
145+
},
146+
},
147+
{
148+
"or symbol at start",
149+
"| a",
150+
SearchSpecs{
151+
MustHave: []string{"|", "a"},
152+
},
153+
},
154+
{
155+
"or symbol at end",
156+
"a |",
157+
SearchSpecs{
158+
MustHave: []string{"a", "|"},
159+
},
160+
},
161+
{
162+
"nots",
163+
"-a -b",
164+
SearchSpecs{
165+
MustNot: []string{"a", "b"},
166+
},
167+
},
168+
{
169+
"not or",
170+
"-a OR b",
171+
SearchSpecs{
172+
AnySets: [][]string{
173+
{"-a", "b"},
174+
},
175+
},
176+
},
177+
{
178+
"not phrase",
179+
`-"a b"`,
180+
SearchSpecs{
181+
MustNot: []string{"a b"},
182+
},
183+
},
184+
{
185+
"not in phrase",
186+
`"-a b"`,
187+
SearchSpecs{
188+
MustHave: []string{"-a b"},
189+
},
190+
},
191+
{
192+
"double not",
193+
"--a",
194+
SearchSpecs{
195+
MustNot: []string{"-a"},
196+
},
197+
},
198+
{
199+
"empty quote",
200+
`"" a`,
201+
SearchSpecs{
202+
MustHave: []string{"a"},
203+
},
204+
},
205+
{
206+
"not empty quote",
207+
`-"" a`,
208+
SearchSpecs{
209+
MustHave: []string{"a"},
210+
},
211+
},
212+
{
213+
"quote in word",
214+
`ab"cd"`,
215+
SearchSpecs{
216+
MustHave: []string{`ab"cd"`},
217+
},
218+
},
219+
}
220+
for _, tt := range tests {
221+
t.Run(tt.name, func(t *testing.T) {
222+
if got := ParseSearchString(tt.q); !reflect.DeepEqual(got, tt.want) {
223+
t.Errorf("FindFilterType.ParseSearchString() = %v, want %v", got, tt.want)
224+
}
225+
})
226+
}
227+
}

0 commit comments

Comments
 (0)