Skip to content

Commit f666b07

Browse files
authored
feat: merge term filters to terms filter with same field (#173)
* feat: merge term filters to terms filter with same field * chore: update docs
1 parent 11436d7 commit f666b07

File tree

4 files changed

+251
-22
lines changed

4 files changed

+251
-22
lines changed

β€Žcore/orm/query.goβ€Ž

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,10 @@ type QueryBuilder struct {
100100
includes []string
101101
excludes []string
102102

103-
defaultOperator string
104-
defaultQueryFields []string
105-
defaultFilterFields []string
103+
defaultOperator string
104+
defaultQueryFields []string
105+
defaultFilterFields []string
106+
defaultFilterOperator string
106107

107108
//indicate fuzziness query is built or not
108109
builtFuzziness bool

β€Žcore/orm/query_args_parser.goβ€Ž

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ func NewQueryBuilderFromRequest(req *http.Request, defaultField ...string) (*Que
4242
builder.defaultFilterFields = strings.Split(reqFilterFields, ",")
4343
}
4444

45+
if reqFilterOperator := q.Get("default_filter_operator"); reqFilterOperator != "" {
46+
builder.defaultFilterOperator = strings.ToUpper(reqFilterOperator)
47+
}
48+
4549
//only if user didn't pass default fields, if user do, use user's value only
4650
if len(defaultField) > 0 && len(defaultFields) == 0 {
4751
defaultFields = append(defaultFields, defaultField...)
@@ -64,6 +68,7 @@ func NewQueryBuilderFromRequest(req *http.Request, defaultField ...string) (*Que
6468
}
6569

6670
// Handle filters (supporting NOT with '-' prefix)
71+
filterClauses := []*Clause{}
6772
for _, filterRaw := range q["filter"] {
6873
filterStr, err := url.QueryUnescape(filterRaw)
6974
if err != nil {
@@ -74,8 +79,17 @@ func NewQueryBuilderFromRequest(req *http.Request, defaultField ...string) (*Que
7479
if err != nil {
7580
return nil, err
7681
}
82+
filterClauses = append(filterClauses, clause)
83+
}
7784

78-
builder.Must(clause)
85+
//check if merge or not
86+
if builder.defaultFilterOperator != "AND" {
87+
// Merge same-field term queries
88+
filterClauses = mergeTermQueries(filterClauses)
89+
}
90+
91+
if len(filterClauses) > 0 {
92+
builder.Must(filterClauses...)
7993
}
8094

8195
// Handle sorting
@@ -232,3 +246,43 @@ func parseValue(val string) interface{} {
232246
}
233247
return val
234248
}
249+
250+
func mergeTermQueries(clauses []*Clause) []*Clause {
251+
grouped := make(map[string][]interface{})
252+
fieldOrder := []string{}
253+
var result []*Clause
254+
255+
for _, clause := range clauses {
256+
// Recurse into sub-clauses
257+
clause.MustClauses = mergeTermQueries(clause.MustClauses)
258+
clause.MustNotClauses = mergeTermQueries(clause.MustNotClauses)
259+
clause.ShouldClauses = mergeTermQueries(clause.ShouldClauses)
260+
clause.FilterClauses = mergeTermQueries(clause.FilterClauses)
261+
262+
// Only merge leaf term queries
263+
if clause.Operator == QueryTerm &&
264+
len(clause.MustClauses) == 0 &&
265+
len(clause.MustNotClauses) == 0 &&
266+
len(clause.ShouldClauses) == 0 &&
267+
len(clause.FilterClauses) == 0 {
268+
if _, seen := grouped[clause.Field]; !seen {
269+
fieldOrder = append(fieldOrder, clause.Field)
270+
}
271+
grouped[clause.Field] = append(grouped[clause.Field], clause.Value)
272+
} else {
273+
result = append(result, clause)
274+
}
275+
}
276+
277+
// Append merged term clauses in original order
278+
for _, field := range fieldOrder {
279+
values := grouped[field]
280+
if len(values) == 1 {
281+
result = append(result, TermQuery(field, values[0]))
282+
} else {
283+
result = append(result, TermsQuery(field, values))
284+
}
285+
}
286+
287+
return result
288+
}

β€Žcore/orm/query_args_parser_test.goβ€Ž

Lines changed: 191 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,26 +124,27 @@ func TestParseQueryParamsToBuilder1(t *testing.T) {
124124
}
125125
walk(root, "must")
126126

127-
// Check count
128-
if len(flatClauses) != len(expected) {
129-
t.Fatalf("Expected %d clauses, got %d", len(expected), len(flatClauses))
130-
}
131-
132-
// Check fields
133-
for i, got := range flatClauses {
134-
exp := expected[i]
127+
// Match clauses ignoring order
128+
matched := make([]bool, len(expected))
135129

136-
if got.logicalType != exp.logicalType {
137-
t.Errorf("Clause %d: expected logicalType %s, got %s", i, exp.logicalType, got.logicalType)
138-
}
139-
if got.clause.Field != exp.field {
140-
t.Errorf("Clause %d: expected field %s, got %s", i, exp.field, got.clause.Field)
141-
}
142-
if got.clause.Operator != exp.operator {
143-
t.Errorf("Clause %d: expected operator %s, got %s", i, exp.operator, got.clause.Operator)
130+
for _, got := range flatClauses {
131+
for i, exp := range expected {
132+
if matched[i] {
133+
continue
134+
}
135+
if got.logicalType == exp.logicalType &&
136+
got.clause.Field == exp.field &&
137+
got.clause.Operator == exp.operator &&
138+
fmt.Sprint(got.clause.Value) == fmt.Sprint(exp.value) {
139+
matched[i] = true
140+
break
141+
}
144142
}
145-
if fmt.Sprint(got.clause.Value) != fmt.Sprint(exp.value) {
146-
t.Errorf("Clause %d: expected value %v, got %v", i, exp.value, got.clause.Value)
143+
}
144+
145+
for i, ok := range matched {
146+
if !ok {
147+
t.Errorf("Expected clause not found: %+v", expected[i])
147148
}
148149
}
149150

@@ -607,3 +608,175 @@ func TestParseAnyTermsQuery(t *testing.T) {
607608
}
608609
}
609610
}
611+
612+
func TestParseQueryWithMergedTermFilters(t *testing.T) {
613+
rawQuery := "query=hello&filter=id:default&filter=id:ai_overview"
614+
req, err := http.NewRequest("GET", "/search?"+rawQuery, nil)
615+
if err != nil {
616+
t.Fatal(err)
617+
}
618+
619+
builder, err := NewQueryBuilderFromRequest(req, "content")
620+
if err != nil {
621+
t.Fatal(err)
622+
}
623+
624+
builder.Build()
625+
root := builder.Root()
626+
if root == nil {
627+
t.Fatal("Expected root clause to be non-nil")
628+
}
629+
630+
var foundQuery, foundTerms bool
631+
632+
for _, clause := range root.MustClauses {
633+
if clause.Operator == QueryMatch &&
634+
clause.Field == "content" &&
635+
clause.Value == "hello" {
636+
foundQuery = true
637+
}
638+
if clause.Operator == QueryTerms &&
639+
clause.Field == "id" {
640+
values, ok := clause.Value.([]interface{})
641+
if !ok {
642+
t.Errorf("Expected terms clause to contain a slice of values")
643+
}
644+
if len(values) != 2 || values[0] != "default" || values[1] != "ai_overview" {
645+
t.Errorf("Unexpected terms values: %v", values)
646+
}
647+
foundTerms = true
648+
}
649+
}
650+
651+
if !foundQuery {
652+
t.Errorf("Expected match query, not found")
653+
}
654+
if !foundTerms {
655+
t.Errorf("Expected merged terms query for 'id', not found")
656+
}
657+
}
658+
659+
660+
func TestMergeTermQueries_SingleField(t *testing.T) {
661+
clauses := []*Clause{
662+
TermQuery("status", "active"),
663+
TermQuery("status", "pending"),
664+
}
665+
666+
merged := mergeTermQueries(clauses)
667+
668+
if len(merged) != 1 {
669+
t.Fatalf("Expected 1 merged clause, got %d", len(merged))
670+
}
671+
672+
clause := merged[0]
673+
if clause.Operator != QueryTerms {
674+
t.Errorf("Expected QueryTerms, got %v", clause.Operator)
675+
}
676+
if clause.Field != "status" {
677+
t.Errorf("Expected field 'status', got %s", clause.Field)
678+
}
679+
values, ok := clause.Value.([]interface{})
680+
if !ok || len(values) != 2 || values[0] != "active" || values[1] != "pending" {
681+
t.Errorf("Unexpected values: %v", clause.Value)
682+
}
683+
}
684+
685+
func TestMergeTermQueries_MultipleFields(t *testing.T) {
686+
clauses := []*Clause{
687+
TermQuery("status", "active"),
688+
TermQuery("type", "admin"),
689+
TermQuery("status", "pending"),
690+
}
691+
692+
merged := mergeTermQueries(clauses)
693+
694+
if len(merged) != 2 {
695+
t.Fatalf("Expected 2 merged clauses, got %d", len(merged))
696+
}
697+
698+
var foundStatus, foundType bool
699+
700+
for _, clause := range merged {
701+
switch clause.Field {
702+
case "status":
703+
if clause.Operator != QueryTerms {
704+
t.Errorf("Expected QueryTerms for 'status', got %v", clause.Operator)
705+
}
706+
foundStatus = true
707+
case "type":
708+
if clause.Operator != QueryTerm {
709+
t.Errorf("Expected QueryTerm for 'type', got %v", clause.Operator)
710+
}
711+
foundType = true
712+
default:
713+
t.Errorf("Unexpected field: %s", clause.Field)
714+
}
715+
}
716+
717+
if !foundStatus || !foundType {
718+
t.Errorf("Missing expected merged clauses")
719+
}
720+
}
721+
722+
func TestMergeTermQueries_WithNonTermQueries(t *testing.T) {
723+
rangeClause := &Clause{
724+
Field: "age",
725+
Operator: QueryRangeGte,
726+
Value: 30,
727+
}
728+
existsClause := ExistsQuery("email")
729+
730+
clauses := []*Clause{
731+
TermQuery("id", "123"),
732+
rangeClause,
733+
TermQuery("id", "456"),
734+
existsClause,
735+
}
736+
737+
merged := mergeTermQueries(clauses)
738+
739+
if len(merged) != 3 {
740+
t.Fatalf("Expected 3 clauses (1 merged + 2 untouched), got %d", len(merged))
741+
}
742+
743+
var foundTerms, foundRange, foundExists bool
744+
745+
for _, clause := range merged {
746+
switch clause.Operator {
747+
case QueryTerms:
748+
if clause.Field == "id" {
749+
values := clause.Value.([]interface{})
750+
if len(values) != 2 {
751+
t.Errorf("Expected 2 terms in merged clause, got %v", values)
752+
}
753+
foundTerms = true
754+
}
755+
case QueryRangeGte:
756+
if clause.Field == "age" {
757+
foundRange = true
758+
}
759+
case QueryExists:
760+
if clause.Field == "email" {
761+
foundExists = true
762+
}
763+
}
764+
}
765+
766+
if !foundTerms || !foundRange || !foundExists {
767+
t.Errorf("Expected all clause types to be present after merge")
768+
}
769+
}
770+
771+
func TestMergeTermQueries_NoMergingNeeded(t *testing.T) {
772+
clauses := []*Clause{
773+
Range("score").Gte(90),
774+
ExistsQuery("email"),
775+
}
776+
777+
merged := mergeTermQueries(clauses)
778+
779+
if len(merged) != 2 {
780+
t.Errorf("Expected 2 unchanged clauses, got %d", len(merged))
781+
}
782+
}

β€Ždocs/content.en/docs/release-notes/_index.mdβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Information about release notes of INFINI Framework is provided here.
1212
### ❌ Breaking changes
1313
### πŸš€ Features
1414
- feat: add hooks for ORM data operation #167
15+
- feat: merge term filters to terms filter with same field #173
1516

1617
### πŸ› Bug fix
1718
- fix: HTTP headers config was not applied with plugin `http`

0 commit comments

Comments
Β (0)