Skip to content

Commit 0b541fa

Browse files
authored
Merge pull request #185 from Quaver/mapset-search-advanced
Introduce new advanced searching of mapsets
2 parents 25af3a6 + 1470797 commit 0b541fa

6 files changed

Lines changed: 630 additions & 6 deletions

File tree

db/advanced_mapset_search.go

Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
package db
2+
3+
import (
4+
"fmt"
5+
"math"
6+
"regexp"
7+
"strconv"
8+
"strings"
9+
10+
"github.com/Quaver/api2/enums"
11+
)
12+
13+
var advancedSearchFilterPattern = regexp.MustCompile(`^([A-Za-z]+)(<=|>=|=|<|>)(\S+)$`)
14+
var advancedSearchFilterLikePattern = regexp.MustCompile(`^[A-Za-z]+(?:<=|>=|=|<|>).*$`)
15+
16+
type advancedRangeBound struct {
17+
floatValue float64
18+
intValue int64
19+
inclusive bool
20+
}
21+
22+
type advancedRangeConstraint struct {
23+
integer bool
24+
lower *advancedRangeBound
25+
upper *advancedRangeBound
26+
}
27+
28+
type parsedAdvancedSearch struct {
29+
text string
30+
modes []enums.GameMode
31+
statuses []enums.RankedStatus
32+
ranges map[string]advancedRangeConstraint
33+
tags []string
34+
hasModes bool
35+
hasStatuses bool
36+
clanRanked bool
37+
}
38+
39+
// ApplyAdvancedSearch parses advanced_search after normal query binding. It deliberately
40+
// only runs for the public mapset-search endpoint, which calls this method.
41+
func (options *ElasticMapsetSearchOptions) ApplyAdvancedSearch() error {
42+
expression := strings.TrimSpace(options.AdvancedSearch)
43+
if expression == "" {
44+
return nil
45+
}
46+
47+
parsed, err := parseAdvancedSearch(expression)
48+
if err != nil {
49+
return err
50+
}
51+
52+
options.Search = parsed.text
53+
options.advancedRanges = parsed.ranges
54+
options.advancedTags = parsed.tags
55+
56+
if parsed.hasModes {
57+
options.Mode = parsed.modes
58+
}
59+
60+
if parsed.hasStatuses {
61+
options.RankedStatus = parsed.statuses
62+
}
63+
64+
if parsed.clanRanked {
65+
options.IsClanRanked = true
66+
}
67+
68+
return nil
69+
}
70+
71+
func parseAdvancedSearch(expression string) (*parsedAdvancedSearch, error) {
72+
parsed := &parsedAdvancedSearch{ranges: make(map[string]advancedRangeConstraint)}
73+
text := make([]string, 0)
74+
modeSeen := make(map[enums.GameMode]bool)
75+
statusSeen := make(map[enums.RankedStatus]bool)
76+
tagSeen := make(map[string]bool)
77+
78+
for _, token := range strings.Fields(expression) {
79+
matches := advancedSearchFilterPattern.FindStringSubmatch(token)
80+
if matches == nil {
81+
if advancedSearchFilterLikePattern.MatchString(token) {
82+
return nil, fmt.Errorf("invalid advanced search filter %q", token)
83+
}
84+
85+
text = append(text, token)
86+
continue
87+
}
88+
89+
field, operator, value := strings.ToLower(matches[1]), matches[2], matches[3]
90+
switch field {
91+
case "t":
92+
if operator != "=" {
93+
return nil, fmt.Errorf("advanced search filter %q only supports =", field)
94+
}
95+
96+
tag, err := advancedSearchTag(value)
97+
if err != nil {
98+
return nil, err
99+
}
100+
101+
if !tagSeen[tag] {
102+
parsed.tags = append(parsed.tags, tag)
103+
tagSeen[tag] = true
104+
}
105+
case "k":
106+
if operator != "=" {
107+
return nil, fmt.Errorf("advanced search filter %q only supports =", field)
108+
}
109+
110+
mode, err := gameModeFromKeyCount(value)
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
parsed.hasModes = true
116+
if !modeSeen[mode] {
117+
parsed.modes = append(parsed.modes, mode)
118+
modeSeen[mode] = true
119+
}
120+
case "s":
121+
if operator != "=" {
122+
return nil, fmt.Errorf("advanced search filter %q only supports =", field)
123+
}
124+
if strings.EqualFold(value, "c") {
125+
parsed.clanRanked = true
126+
continue
127+
}
128+
129+
status, err := rankedStatusFromAdvancedSearch(value)
130+
if err != nil {
131+
return nil, err
132+
}
133+
134+
parsed.hasStatuses = true
135+
if !statusSeen[status] {
136+
parsed.statuses = append(parsed.statuses, status)
137+
statusSeen[status] = true
138+
}
139+
case "b", "d", "l", "ln", "pc", "lu":
140+
fieldName, integer := advancedRangeField(field)
141+
bound, err := parseAdvancedRangeBound(value, operator, integer)
142+
if err != nil {
143+
return nil, fmt.Errorf("invalid value for advanced search filter %q: %w", field, err)
144+
}
145+
146+
constraint := parsed.ranges[fieldName]
147+
constraint.integer = integer
148+
if err := constraint.add(operator, bound); err != nil {
149+
return nil, fmt.Errorf("invalid advanced search filter %q: %w", field, err)
150+
}
151+
parsed.ranges[fieldName] = constraint
152+
default:
153+
return nil, fmt.Errorf("unknown advanced search filter %q", field)
154+
}
155+
}
156+
157+
parsed.text = strings.Join(text, " ")
158+
return parsed, nil
159+
}
160+
161+
func advancedSearchTag(value string) (string, error) {
162+
tag := strings.ToLower(value)
163+
tag = strings.NewReplacer("_", " ", "-", " ").Replace(tag)
164+
for _, validTag := range tagSearchTerms {
165+
if tag == validTag {
166+
return validTag, nil
167+
}
168+
}
169+
170+
return "", fmt.Errorf("invalid tag %q", value)
171+
}
172+
173+
func advancedRangeField(field string) (string, bool) {
174+
switch field {
175+
case "b":
176+
return "bpm", false
177+
case "d":
178+
return "difficulty_rating", false
179+
case "l":
180+
return "length", false
181+
case "ln":
182+
return "long_note_percentage", false
183+
case "pc":
184+
return "play_count", true
185+
case "lu":
186+
return "date_last_updated", true
187+
default:
188+
panic("unsupported advanced range field")
189+
}
190+
}
191+
192+
func parseAdvancedRangeBound(value, operator string, integer bool) (advancedRangeBound, error) {
193+
bound := advancedRangeBound{inclusive: operator == "=" || operator == ">=" || operator == "<="}
194+
195+
if integer {
196+
parsed, err := strconv.ParseInt(value, 10, 64)
197+
if err != nil {
198+
return advancedRangeBound{}, fmt.Errorf("must be an integer")
199+
}
200+
bound.intValue = parsed
201+
return bound, nil
202+
}
203+
204+
parsed, err := strconv.ParseFloat(value, 64)
205+
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
206+
return advancedRangeBound{}, fmt.Errorf("must be a finite number")
207+
}
208+
bound.floatValue = parsed
209+
return bound, nil
210+
}
211+
212+
func (constraint *advancedRangeConstraint) add(operator string, bound advancedRangeBound) error {
213+
if operator == "=" {
214+
constraint.addLower(bound)
215+
constraint.addUpper(bound)
216+
} else if operator == ">" || operator == ">=" {
217+
constraint.addLower(bound)
218+
} else {
219+
constraint.addUpper(bound)
220+
}
221+
222+
if constraint.lower == nil || constraint.upper == nil {
223+
return nil
224+
}
225+
226+
comparison := constraint.compare(*constraint.lower, *constraint.upper)
227+
if comparison > 0 || comparison == 0 && (!constraint.lower.inclusive || !constraint.upper.inclusive) {
228+
return fmt.Errorf("range bounds do not overlap")
229+
}
230+
231+
return nil
232+
}
233+
234+
func (constraint *advancedRangeConstraint) addLower(bound advancedRangeBound) {
235+
if constraint.lower == nil || constraint.compare(bound, *constraint.lower) > 0 ||
236+
constraint.compare(bound, *constraint.lower) == 0 && !bound.inclusive && constraint.lower.inclusive {
237+
constraint.lower = &bound
238+
}
239+
}
240+
241+
func (constraint *advancedRangeConstraint) addUpper(bound advancedRangeBound) {
242+
if constraint.upper == nil || constraint.compare(bound, *constraint.upper) < 0 ||
243+
constraint.compare(bound, *constraint.upper) == 0 && !bound.inclusive && constraint.upper.inclusive {
244+
constraint.upper = &bound
245+
}
246+
}
247+
248+
func (constraint advancedRangeConstraint) compare(left, right advancedRangeBound) int {
249+
if constraint.integer {
250+
switch {
251+
case left.intValue < right.intValue:
252+
return -1
253+
case left.intValue > right.intValue:
254+
return 1
255+
default:
256+
return 0
257+
}
258+
}
259+
260+
switch {
261+
case left.floatValue < right.floatValue:
262+
return -1
263+
case left.floatValue > right.floatValue:
264+
return 1
265+
default:
266+
return 0
267+
}
268+
}
269+
270+
func gameModeFromKeyCount(value string) (enums.GameMode, error) {
271+
keyCount, err := strconv.Atoi(value)
272+
if err != nil {
273+
return 0, fmt.Errorf("invalid game mode %q", value)
274+
}
275+
276+
modes := map[int]enums.GameMode{
277+
1: enums.GameModeKeys1, 2: enums.GameModeKeys2, 3: enums.GameModeKeys3,
278+
4: enums.GameModeKeys4, 5: enums.GameModeKeys5, 6: enums.GameModeKeys6,
279+
7: enums.GameModeKeys7, 8: enums.GameModeKeys8, 9: enums.GameModeKeys9,
280+
10: enums.GameModeKeys10,
281+
}
282+
283+
mode, exists := modes[keyCount]
284+
if !exists {
285+
return 0, fmt.Errorf("invalid game mode %q", value)
286+
}
287+
288+
return mode, nil
289+
}
290+
291+
func rankedStatusFromAdvancedSearch(value string) (enums.RankedStatus, error) {
292+
switch strings.ToLower(value) {
293+
case "r":
294+
return enums.RankedStatusRanked, nil
295+
case "u":
296+
return enums.RankedStatusUnranked, nil
297+
default:
298+
return 0, fmt.Errorf("invalid ranked status %q (expected r, u, or c)", value)
299+
}
300+
}
301+
302+
func addSearchRangeQuery[T Number](boolQuery *BoolQuery, options *ElasticMapsetSearchOptions, field string, min T, max T) {
303+
if constraint, exists := options.advancedRanges[field]; exists {
304+
addAdvancedRangeQuery(boolQuery, field, constraint)
305+
return
306+
}
307+
308+
addRangeQuery(boolQuery, field, min, max)
309+
}
310+
311+
func addAdvancedRangeQuery(boolQuery *BoolQuery, field string, constraint advancedRangeConstraint) {
312+
rangeQuery := Range{}
313+
314+
if constraint.lower != nil {
315+
value := advancedRangeValue(*constraint.lower, constraint.integer)
316+
if constraint.lower.inclusive {
317+
rangeQuery.Gte = value
318+
} else {
319+
rangeQuery.Gt = value
320+
}
321+
}
322+
323+
if constraint.upper != nil {
324+
value := advancedRangeValue(*constraint.upper, constraint.integer)
325+
if constraint.upper.inclusive {
326+
rangeQuery.Lte = value
327+
} else {
328+
rangeQuery.Lt = value
329+
}
330+
}
331+
332+
boolQuery.BoolQuery.Must = append(boolQuery.BoolQuery.Must, RangeCustom{
333+
Range: map[string]Range{field: rangeQuery},
334+
})
335+
}
336+
337+
func advancedRangeValue(bound advancedRangeBound, integer bool) interface{} {
338+
if integer {
339+
return bound.intValue
340+
}
341+
342+
return bound.floatValue
343+
}

0 commit comments

Comments
 (0)