mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-12 01:59:34 -05:00
fix: merge AND-joined sub-table filters into single EXISTS subquery
When multiple AND-joined filter conditions target the same sub-table (e.g., reminders > X && reminders < Y), they are now combined into a single EXISTS subquery so that all conditions must be satisfied by the same row. Previously, each condition generated a separate EXISTS subquery that could match different rows, causing false positives. Fixes #2245
This commit is contained in:
@@ -159,8 +159,12 @@ func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error)
|
||||
func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (filterCond builder.Cond, err error) {
|
||||
|
||||
var dbFilters = make([]builder.Cond, 0, len(rawFilters))
|
||||
// To still find tasks with nil values, we exclude 0s when comparing with >/< values.
|
||||
for _, f := range rawFilters {
|
||||
// Track join types separately because after merging consecutive sub-table
|
||||
// filters, the indexes of dbFilters no longer correspond 1:1 with rawFilters.
|
||||
var dbFilterJoins = make([]taskFilterConcatinator, 0, len(rawFilters))
|
||||
|
||||
for i := 0; i < len(rawFilters); i++ {
|
||||
f := rawFilters[i]
|
||||
|
||||
if nested, is := f.value.([]*taskFilter); is {
|
||||
nestedDBFilters, err := convertFiltersToDBFilterCond(nested, includeNulls)
|
||||
@@ -168,6 +172,7 @@ func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (
|
||||
return nil, err
|
||||
}
|
||||
dbFilters = append(dbFilters, nestedDBFilters)
|
||||
dbFilterJoins = append(dbFilterJoins, f.join)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -177,39 +182,69 @@ func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (
|
||||
continue
|
||||
}
|
||||
|
||||
comparator := f.comparator
|
||||
_, ok = strictComparators[f.comparator]
|
||||
// we will select all specified values in both cases, negative and positive filtering.
|
||||
// but later we will eather check their existence or absence of them.
|
||||
if ok {
|
||||
comparator = taskFilterComparatorIn
|
||||
// Collect all consecutive AND-joined filters targeting the same sub-table
|
||||
group := []*taskFilter{f}
|
||||
for i+1 < len(rawFilters) {
|
||||
next := rawFilters[i+1]
|
||||
nextSubTable, nextOk := subTableFilters[next.field]
|
||||
if !nextOk || nextSubTable.Table != subTableFilterParams.Table || next.join != filterConcatAnd {
|
||||
break
|
||||
}
|
||||
if next.field == "assignees" && next.comparator == taskFilterComparatorLike {
|
||||
break
|
||||
}
|
||||
// Don't mix positive and negative filters (EXISTS vs NOT EXISTS)
|
||||
currentIsNegative := f.comparator == taskFilterComparatorNotEquals || f.comparator == taskFilterComparatorNotIn
|
||||
nextIsNegative := next.comparator == taskFilterComparatorNotEquals || next.comparator == taskFilterComparatorNotIn
|
||||
if currentIsNegative != nextIsNegative {
|
||||
break
|
||||
}
|
||||
group = append(group, next)
|
||||
i++
|
||||
}
|
||||
|
||||
filter, err := getFilterCond(&taskFilter{
|
||||
// recreating the struct here to avoid modifying it when reusing the opts struct
|
||||
field: subTableFilterParams.FilterableField,
|
||||
value: f.value,
|
||||
comparator: comparator,
|
||||
isNumeric: f.isNumeric,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Build the combined condition for all filters in the group
|
||||
var combinedInnerCond builder.Cond
|
||||
for _, gf := range group {
|
||||
comparator := gf.comparator
|
||||
_, isStrict := strictComparators[gf.comparator]
|
||||
if isStrict {
|
||||
comparator = taskFilterComparatorIn
|
||||
}
|
||||
|
||||
innerFilter, err := getFilterCond(&taskFilter{
|
||||
field: subTableFilterParams.FilterableField,
|
||||
value: gf.value,
|
||||
comparator: comparator,
|
||||
isNumeric: gf.isNumeric,
|
||||
}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if combinedInnerCond == nil {
|
||||
combinedInnerCond = innerFilter
|
||||
} else {
|
||||
combinedInnerCond = builder.And(combinedInnerCond, innerFilter)
|
||||
}
|
||||
}
|
||||
|
||||
filterSubQuery := subTableFilterParams.ToBaseSubQuery().And(filter)
|
||||
filterSubQuery := subTableFilterParams.ToBaseSubQuery().And(combinedInnerCond)
|
||||
|
||||
if f.comparator == taskFilterComparatorNotEquals || f.comparator == taskFilterComparatorNotIn {
|
||||
var filter builder.Cond
|
||||
if group[0].comparator == taskFilterComparatorNotEquals || group[0].comparator == taskFilterComparatorNotIn {
|
||||
filter = builder.NotExists(filterSubQuery)
|
||||
} else {
|
||||
filter = builder.Exists(filterSubQuery)
|
||||
}
|
||||
|
||||
if includeNulls && subTableFilterParams.AllowNullCheck {
|
||||
// check that we have no any connected values for this field
|
||||
filter = builder.Or(filter, builder.NotExists(subTableFilterParams.ToBaseSubQuery()))
|
||||
}
|
||||
|
||||
dbFilters = append(dbFilters, filter)
|
||||
// Use the join from the last filter in the group (how this group connects to the next filter)
|
||||
dbFilterJoins = append(dbFilterJoins, group[len(group)-1].join)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -223,6 +258,7 @@ func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (
|
||||
return nil, err
|
||||
}
|
||||
dbFilters = append(dbFilters, filter)
|
||||
dbFilterJoins = append(dbFilterJoins, f.join)
|
||||
}
|
||||
|
||||
if len(dbFilters) > 0 {
|
||||
@@ -230,7 +266,7 @@ func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (
|
||||
if len(dbFilters) >= 1 {
|
||||
for i := range dbFilters {
|
||||
if len(dbFilters) > i+1 {
|
||||
switch rawFilters[i+1].join {
|
||||
switch dbFilterJoins[i+1] {
|
||||
case filterConcatOr:
|
||||
filterCond = builder.Or(filterCond, dbFilters[i+1])
|
||||
case filterConcatAnd:
|
||||
|
||||
Reference in New Issue
Block a user