Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions core/orm/query.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,10 @@ type QueryBuilder struct {
includes []string
excludes []string

defaultOperator string
defaultQueryFields []string
defaultFilterFields []string
defaultOperator string
defaultQueryFields []string
defaultFilterFields []string
defaultFilterOperator string

//indicate fuzziness query is built or not
builtFuzziness bool
Expand Down
56 changes: 55 additions & 1 deletion core/orm/query_args_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ func NewQueryBuilderFromRequest(req *http.Request, defaultField ...string) (*Que
builder.defaultFilterFields = strings.Split(reqFilterFields, ",")
}

if reqFilterOperator := q.Get("default_filter_operator"); reqFilterOperator != "" {
builder.defaultFilterOperator = strings.ToUpper(reqFilterOperator)
}

//only if user didn't pass default fields, if user do, use user's value only
if len(defaultField) > 0 && len(defaultFields) == 0 {
defaultFields = append(defaultFields, defaultField...)
Expand All @@ -64,6 +68,7 @@ func NewQueryBuilderFromRequest(req *http.Request, defaultField ...string) (*Que
}

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

builder.Must(clause)
//check if merge or not
if builder.defaultFilterOperator != "AND" {
// Merge same-field term queries
filterClauses = mergeTermQueries(filterClauses)
}

if len(filterClauses) > 0 {
builder.Must(filterClauses...)
}

// Handle sorting
Expand Down Expand Up @@ -232,3 +246,43 @@ func parseValue(val string) interface{} {
}
return val
}

func mergeTermQueries(clauses []*Clause) []*Clause {
grouped := make(map[string][]interface{})
fieldOrder := []string{}
var result []*Clause

for _, clause := range clauses {
// Recurse into sub-clauses
clause.MustClauses = mergeTermQueries(clause.MustClauses)
clause.MustNotClauses = mergeTermQueries(clause.MustNotClauses)
clause.ShouldClauses = mergeTermQueries(clause.ShouldClauses)
clause.FilterClauses = mergeTermQueries(clause.FilterClauses)

// Only merge leaf term queries
if clause.Operator == QueryTerm &&
len(clause.MustClauses) == 0 &&
len(clause.MustNotClauses) == 0 &&
len(clause.ShouldClauses) == 0 &&
len(clause.FilterClauses) == 0 {
if _, seen := grouped[clause.Field]; !seen {
fieldOrder = append(fieldOrder, clause.Field)
}
grouped[clause.Field] = append(grouped[clause.Field], clause.Value)
} else {
result = append(result, clause)
}
}

// Append merged term clauses in original order
for _, field := range fieldOrder {
values := grouped[field]
if len(values) == 1 {
result = append(result, TermQuery(field, values[0]))
} else {
result = append(result, TermsQuery(field, values))
}
}

return result
}
209 changes: 191 additions & 18 deletions core/orm/query_args_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,26 +124,27 @@ func TestParseQueryParamsToBuilder1(t *testing.T) {
}
walk(root, "must")

// Check count
if len(flatClauses) != len(expected) {
t.Fatalf("Expected %d clauses, got %d", len(expected), len(flatClauses))
}

// Check fields
for i, got := range flatClauses {
exp := expected[i]
// Match clauses ignoring order
matched := make([]bool, len(expected))

if got.logicalType != exp.logicalType {
t.Errorf("Clause %d: expected logicalType %s, got %s", i, exp.logicalType, got.logicalType)
}
if got.clause.Field != exp.field {
t.Errorf("Clause %d: expected field %s, got %s", i, exp.field, got.clause.Field)
}
if got.clause.Operator != exp.operator {
t.Errorf("Clause %d: expected operator %s, got %s", i, exp.operator, got.clause.Operator)
for _, got := range flatClauses {
for i, exp := range expected {
if matched[i] {
continue
}
if got.logicalType == exp.logicalType &&
got.clause.Field == exp.field &&
got.clause.Operator == exp.operator &&
fmt.Sprint(got.clause.Value) == fmt.Sprint(exp.value) {
matched[i] = true
break
}
}
if fmt.Sprint(got.clause.Value) != fmt.Sprint(exp.value) {
t.Errorf("Clause %d: expected value %v, got %v", i, exp.value, got.clause.Value)
}

for i, ok := range matched {
if !ok {
t.Errorf("Expected clause not found: %+v", expected[i])
}
}

Expand Down Expand Up @@ -607,3 +608,175 @@ func TestParseAnyTermsQuery(t *testing.T) {
}
}
}

func TestParseQueryWithMergedTermFilters(t *testing.T) {
rawQuery := "query=hello&filter=id:default&filter=id:ai_overview"
req, err := http.NewRequest("GET", "/search?"+rawQuery, nil)
if err != nil {
t.Fatal(err)
}

builder, err := NewQueryBuilderFromRequest(req, "content")
if err != nil {
t.Fatal(err)
}

builder.Build()
root := builder.Root()
if root == nil {
t.Fatal("Expected root clause to be non-nil")
}

var foundQuery, foundTerms bool

for _, clause := range root.MustClauses {
if clause.Operator == QueryMatch &&
clause.Field == "content" &&
clause.Value == "hello" {
foundQuery = true
}
if clause.Operator == QueryTerms &&
clause.Field == "id" {
values, ok := clause.Value.([]interface{})
if !ok {
t.Errorf("Expected terms clause to contain a slice of values")
}
if len(values) != 2 || values[0] != "default" || values[1] != "ai_overview" {
t.Errorf("Unexpected terms values: %v", values)
}
foundTerms = true
}
}

if !foundQuery {
t.Errorf("Expected match query, not found")
}
if !foundTerms {
t.Errorf("Expected merged terms query for 'id', not found")
}
}


func TestMergeTermQueries_SingleField(t *testing.T) {
clauses := []*Clause{
TermQuery("status", "active"),
TermQuery("status", "pending"),
}

merged := mergeTermQueries(clauses)

if len(merged) != 1 {
t.Fatalf("Expected 1 merged clause, got %d", len(merged))
}

clause := merged[0]
if clause.Operator != QueryTerms {
t.Errorf("Expected QueryTerms, got %v", clause.Operator)
}
if clause.Field != "status" {
t.Errorf("Expected field 'status', got %s", clause.Field)
}
values, ok := clause.Value.([]interface{})
if !ok || len(values) != 2 || values[0] != "active" || values[1] != "pending" {
t.Errorf("Unexpected values: %v", clause.Value)
}
}

func TestMergeTermQueries_MultipleFields(t *testing.T) {
clauses := []*Clause{
TermQuery("status", "active"),
TermQuery("type", "admin"),
TermQuery("status", "pending"),
}

merged := mergeTermQueries(clauses)

if len(merged) != 2 {
t.Fatalf("Expected 2 merged clauses, got %d", len(merged))
}

var foundStatus, foundType bool

for _, clause := range merged {
switch clause.Field {
case "status":
if clause.Operator != QueryTerms {
t.Errorf("Expected QueryTerms for 'status', got %v", clause.Operator)
}
foundStatus = true
case "type":
if clause.Operator != QueryTerm {
t.Errorf("Expected QueryTerm for 'type', got %v", clause.Operator)
}
foundType = true
default:
t.Errorf("Unexpected field: %s", clause.Field)
}
}

if !foundStatus || !foundType {
t.Errorf("Missing expected merged clauses")
}
}

func TestMergeTermQueries_WithNonTermQueries(t *testing.T) {
rangeClause := &Clause{
Field: "age",
Operator: QueryRangeGte,
Value: 30,
}
existsClause := ExistsQuery("email")

clauses := []*Clause{
TermQuery("id", "123"),
rangeClause,
TermQuery("id", "456"),
existsClause,
}

merged := mergeTermQueries(clauses)

if len(merged) != 3 {
t.Fatalf("Expected 3 clauses (1 merged + 2 untouched), got %d", len(merged))
}

var foundTerms, foundRange, foundExists bool

for _, clause := range merged {
switch clause.Operator {
case QueryTerms:
if clause.Field == "id" {
values := clause.Value.([]interface{})
if len(values) != 2 {
t.Errorf("Expected 2 terms in merged clause, got %v", values)
}
foundTerms = true
}
case QueryRangeGte:
if clause.Field == "age" {
foundRange = true
}
case QueryExists:
if clause.Field == "email" {
foundExists = true
}
}
}

if !foundTerms || !foundRange || !foundExists {
t.Errorf("Expected all clause types to be present after merge")
}
}

func TestMergeTermQueries_NoMergingNeeded(t *testing.T) {
clauses := []*Clause{
Range("score").Gte(90),
ExistsQuery("email"),
}

merged := mergeTermQueries(clauses)

if len(merged) != 2 {
t.Errorf("Expected 2 unchanged clauses, got %d", len(merged))
}
}
1 change: 1 addition & 0 deletions docs/content.en/docs/release-notes/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Information about release notes of INFINI Framework is provided here.
### ❌ Breaking changes
### 🚀 Features
- feat: add hooks for ORM data operation #167
- feat: merge term filters to terms filter with same field #173

### 🐛 Bug fix
- fix: HTTP headers config was not applied with plugin `http`
Expand Down
Loading