mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-08 21:08:04 -05:00
When expand[]=subtasks is used, the LEFT JOIN on task_relations filters
out same-project subtasks. If a parent task was deleted, the JOIN
produces NULL for parent_tasks.id/project_id, and since NULL != value
evaluates to NULL (not TRUE) in SQL, these tasks were incorrectly
excluded.
Add builder.IsNull{"parent_tasks.id"} to the OR condition so tasks
whose parent was deleted are still included in results.
488 lines
14 KiB
Go
488 lines
14 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"code.vikunja.io/api/pkg/db"
|
|
"code.vikunja.io/api/pkg/web"
|
|
|
|
"xorm.io/builder"
|
|
"xorm.io/xorm"
|
|
"xorm.io/xorm/schemas"
|
|
)
|
|
|
|
type SubTableFilter struct {
|
|
Table string
|
|
BaseFilter string
|
|
FilterableField string
|
|
AllowNullCheck bool
|
|
}
|
|
|
|
type SubTableFilters map[string]SubTableFilter
|
|
|
|
var subTableFilters = SubTableFilters{
|
|
"labels": {
|
|
Table: "label_tasks",
|
|
BaseFilter: "tasks.id = task_id",
|
|
FilterableField: "label_id",
|
|
AllowNullCheck: true,
|
|
},
|
|
"label_id": {
|
|
Table: "label_tasks",
|
|
BaseFilter: "tasks.id = task_id",
|
|
FilterableField: "label_id",
|
|
AllowNullCheck: true,
|
|
},
|
|
"reminders": {
|
|
Table: "task_reminders",
|
|
BaseFilter: "tasks.id = task_id",
|
|
FilterableField: "reminder",
|
|
AllowNullCheck: true,
|
|
},
|
|
"assignees": {
|
|
Table: "task_assignees",
|
|
BaseFilter: "tasks.id = task_id",
|
|
FilterableField: "username",
|
|
AllowNullCheck: true,
|
|
},
|
|
"parent_project": {
|
|
Table: "projects",
|
|
BaseFilter: "tasks.project_id = id",
|
|
FilterableField: "parent_project_id",
|
|
AllowNullCheck: false,
|
|
},
|
|
"parent_project_id": {
|
|
Table: "projects",
|
|
BaseFilter: "tasks.project_id = id",
|
|
FilterableField: "parent_project_id",
|
|
AllowNullCheck: false,
|
|
},
|
|
}
|
|
|
|
var strictComparators = map[taskFilterComparator]bool{
|
|
taskFilterComparatorIn: true,
|
|
taskFilterComparatorNotIn: true,
|
|
taskFilterComparatorEquals: true,
|
|
taskFilterComparatorNotEquals: true,
|
|
}
|
|
|
|
// isRangeComparator returns true for comparators where combining multiple
|
|
// conditions into a single EXISTS subquery is semantically correct (i.e. a
|
|
// single row can satisfy both conditions simultaneously).
|
|
func isRangeComparator(c taskFilterComparator) bool {
|
|
return c == taskFilterComparatorGreater ||
|
|
c == taskFilterComparatorGreateEquals ||
|
|
c == taskFilterComparatorLess ||
|
|
c == taskFilterComparatorLessEquals
|
|
}
|
|
|
|
type taskSearcher interface {
|
|
Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error)
|
|
}
|
|
|
|
type dbTaskSearcher struct {
|
|
s *xorm.Session
|
|
a web.Auth
|
|
hasFavoritesProject bool
|
|
}
|
|
|
|
func (sf *SubTableFilter) ToBaseSubQuery() *builder.Builder {
|
|
var cond = builder.
|
|
Select("1").
|
|
From(sf.Table).
|
|
Where(builder.Expr(sf.BaseFilter))
|
|
|
|
// little hack to add users table for assignees filter
|
|
if sf.Table == "task_assignees" {
|
|
cond.Join("INNER", "users", "users.id = user_id")
|
|
}
|
|
|
|
return cond
|
|
}
|
|
|
|
func getOrderByDBStatement(opts *taskSearchOptions) (orderby string, err error) {
|
|
// Since xorm does not use placeholders for order by, it is possible to expose this with sql injection if we're directly
|
|
// passing user input to the db.
|
|
// As a workaround to prevent this, we check for valid column names here prior to passing it to the db.
|
|
for i, param := range opts.sortby {
|
|
// Validate the params
|
|
if err := param.validate(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var prefix string
|
|
switch param.sortBy {
|
|
case taskPropertyPosition:
|
|
prefix = "task_positions."
|
|
case taskPropertyBucketID:
|
|
prefix = "task_buckets."
|
|
default:
|
|
prefix = "tasks."
|
|
}
|
|
|
|
// Mysql sorts columns with null values before ones without null value.
|
|
// Because it does not have support for NULLS FIRST or NULLS LAST we work around this by
|
|
// first sorting for null (or not null) values and then the order we actually want to.
|
|
if db.Type() == schemas.MYSQL {
|
|
orderby += prefix + "`" + param.sortBy + "` IS NULL, "
|
|
}
|
|
|
|
orderby += prefix + "`" + param.sortBy + "` " + param.orderBy.String()
|
|
|
|
// Postgres and sqlite allow us to control how columns with null values are sorted.
|
|
// To make that consistent with the sort order we have and other dbms, we're adding a separate clause here.
|
|
if db.Type() == schemas.POSTGRES || db.Type() == schemas.SQLITE {
|
|
orderby += " NULLS LAST"
|
|
}
|
|
|
|
if (i + 1) < len(opts.sortby) {
|
|
orderby += ", "
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func convertFiltersToDBFilterCond(rawFilters []*taskFilter, includeNulls bool) (filterCond builder.Cond, err error) {
|
|
|
|
var dbFilters = make([]builder.Cond, 0, len(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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dbFilters = append(dbFilters, nestedDBFilters)
|
|
dbFilterJoins = append(dbFilterJoins, f.join)
|
|
continue
|
|
}
|
|
|
|
subTableFilterParams, ok := subTableFilters[f.field]
|
|
if ok {
|
|
if f.field == "assignees" && (f.comparator == taskFilterComparatorLike) {
|
|
continue
|
|
}
|
|
|
|
// Collect all consecutive AND-joined range filters targeting the same sub-table.
|
|
// Only range comparators (>, >=, <, <=) are merged because they express
|
|
// conditions a single row can satisfy simultaneously (e.g. reminder > X AND
|
|
// reminder < Y). Equality/IN/NOT comparators must remain as separate EXISTS
|
|
// subqueries because each matching value lives in its own row (e.g.
|
|
// labels = 4 && labels = 5 means two different rows must each exist).
|
|
group := []*taskFilter{f}
|
|
if isRangeComparator(f.comparator) {
|
|
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 !isRangeComparator(next.comparator) {
|
|
break
|
|
}
|
|
group = append(group, next)
|
|
i++
|
|
}
|
|
}
|
|
|
|
// 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(combinedInnerCond)
|
|
|
|
var filter builder.Cond
|
|
if f.comparator == taskFilterComparatorNotEquals || f.comparator == taskFilterComparatorNotIn {
|
|
filter = builder.NotExists(filterSubQuery)
|
|
} else {
|
|
filter = builder.Exists(filterSubQuery)
|
|
}
|
|
|
|
if includeNulls && subTableFilterParams.AllowNullCheck {
|
|
filter = builder.Or(filter, builder.NotExists(subTableFilterParams.ToBaseSubQuery()))
|
|
}
|
|
|
|
dbFilters = append(dbFilters, filter)
|
|
// Use the join from the first filter in the group: f.join describes how
|
|
// this group connects to the previous element (matches the convention
|
|
// where dbFilterJoins[i+1] combines dbFilters[i] with dbFilters[i+1]).
|
|
dbFilterJoins = append(dbFilterJoins, f.join)
|
|
continue
|
|
}
|
|
|
|
if f.field == taskPropertyBucketID {
|
|
f.field = "task_buckets.`bucket_id`"
|
|
} else {
|
|
f.field = "tasks.`" + f.field + "`"
|
|
}
|
|
filter, err := getFilterCond(f, includeNulls)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dbFilters = append(dbFilters, filter)
|
|
dbFilterJoins = append(dbFilterJoins, f.join)
|
|
}
|
|
|
|
if len(dbFilters) > 0 {
|
|
filterCond = dbFilters[0]
|
|
if len(dbFilters) >= 1 {
|
|
for i := range dbFilters {
|
|
if len(dbFilters) > i+1 {
|
|
switch dbFilterJoins[i+1] {
|
|
case filterConcatOr:
|
|
filterCond = builder.Or(filterCond, dbFilters[i+1])
|
|
case filterConcatAnd:
|
|
filterCond = builder.And(filterCond, dbFilters[i+1])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return filterCond, nil
|
|
}
|
|
|
|
func hasBucketIDInParsedFilter(filters []*taskFilter) bool {
|
|
for _, filter := range filters {
|
|
if subfilters, is := filter.value.([]*taskFilter); is {
|
|
has := hasBucketIDInParsedFilter(subfilters)
|
|
if has {
|
|
return true
|
|
}
|
|
}
|
|
if filter.field == taskPropertyBucketID {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
//nolint:gocyclo
|
|
func (d *dbTaskSearcher) Search(opts *taskSearchOptions) (tasks []*Task, totalCount int64, err error) {
|
|
|
|
orderby, err := getOrderByDBStatement(opts)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
joinTaskBuckets := hasBucketIDInParsedFilter(opts.parsedFilters)
|
|
|
|
filterCond, err := convertFiltersToDBFilterCond(opts.parsedFilters, opts.filterIncludeNulls)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Then return all tasks for that projects
|
|
var where builder.Cond
|
|
|
|
if opts.search != "" {
|
|
where = db.MultiFieldSearchWithTableAlias([]string{"title", "description"}, opts.search, "tasks")
|
|
|
|
searchIndex := getTaskIndexFromSearchString(opts.search)
|
|
if searchIndex > 0 {
|
|
where = builder.Or(where, builder.Eq{"`index`": searchIndex})
|
|
}
|
|
}
|
|
|
|
var projectIDCond builder.Cond
|
|
var favoritesCond builder.Cond
|
|
if len(opts.projectIDs) > 0 {
|
|
projectIDCond = builder.In("tasks.project_id", opts.projectIDs)
|
|
}
|
|
|
|
if d.hasFavoritesProject {
|
|
// All favorite tasks for that user
|
|
favCond := builder.
|
|
Select("entity_id").
|
|
From("favorites").
|
|
Where(
|
|
builder.And(
|
|
builder.Eq{"user_id": d.a.GetID()},
|
|
builder.Eq{"kind": FavoriteKindTask},
|
|
))
|
|
|
|
favoritesCond = builder.In("tasks.id", favCond)
|
|
}
|
|
|
|
limit, start := getLimitFromPageIndex(opts.page, opts.perPage)
|
|
cond := builder.And(builder.Or(projectIDCond, favoritesCond), where, filterCond)
|
|
|
|
var distinct = "tasks.*"
|
|
if strings.Contains(orderby, "task_positions.") {
|
|
distinct += ", task_positions.position"
|
|
}
|
|
|
|
var expandSubtasks = false
|
|
for _, expandable := range opts.expand {
|
|
if expandable == TaskCollectionExpandSubtasks {
|
|
expandSubtasks = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if expandSubtasks {
|
|
cond = builder.And(cond, builder.Or(
|
|
builder.IsNull{"task_relations.id"},
|
|
builder.IsNull{"parent_tasks.id"},
|
|
builder.Expr("parent_tasks.project_id != tasks.project_id"),
|
|
))
|
|
}
|
|
|
|
query := d.s.
|
|
Distinct(distinct).
|
|
Where(cond)
|
|
if limit > 0 {
|
|
query = query.Limit(limit, start)
|
|
}
|
|
|
|
for _, param := range opts.sortby {
|
|
if param.sortBy == taskPropertyPosition {
|
|
query = query.Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", param.projectViewID)
|
|
break
|
|
}
|
|
}
|
|
|
|
if joinTaskBuckets {
|
|
joinCond := "task_buckets.task_id = tasks.id"
|
|
if opts.projectViewID > 0 {
|
|
joinCond += " AND task_buckets.project_view_id = ?"
|
|
query = query.Join("LEFT", "task_buckets", joinCond, opts.projectViewID)
|
|
} else {
|
|
query = query.Join("LEFT", "task_buckets", joinCond)
|
|
}
|
|
}
|
|
if expandSubtasks {
|
|
query = query.
|
|
Join("LEFT", "task_relations", "tasks.id = task_relations.task_id and task_relations.relation_kind = 'parenttask'").
|
|
Join("LEFT", "tasks parent_tasks", "task_relations.other_task_id = parent_tasks.id")
|
|
}
|
|
|
|
tasks = []*Task{}
|
|
err = query.
|
|
OrderBy(orderby).
|
|
Find(&tasks)
|
|
if err != nil {
|
|
sql, vals := query.LastSQL()
|
|
return nil, 0, fmt.Errorf("could not fetch tasks, error was '%w', sql: '%v', values: %v", err, sql, vals)
|
|
}
|
|
|
|
// fetch subtasks when expanding
|
|
if expandSubtasks && len(tasks) > 0 {
|
|
subtasks := []*Task{}
|
|
|
|
taskIDs := []any{}
|
|
for _, task := range tasks {
|
|
taskIDs = append(taskIDs, task.ID)
|
|
}
|
|
|
|
var inPlaceholders = strings.Repeat("?,", len(taskIDs))
|
|
inPlaceholders = inPlaceholders[:len(inPlaceholders)-1]
|
|
|
|
var notIn = strings.Repeat("?,", len(taskIDs))
|
|
notIn = notIn[:len(notIn)-1]
|
|
|
|
allArgs := make([]any, 0, len(taskIDs)*2)
|
|
allArgs = append(allArgs, taskIDs...)
|
|
allArgs = append(allArgs, taskIDs...)
|
|
|
|
err = d.s.SQL(`SELECT * FROM tasks WHERE id IN (WITH RECURSIVE sub_tasks AS (
|
|
SELECT task_id,
|
|
other_task_id,
|
|
relation_kind,
|
|
created_by_id,
|
|
created
|
|
FROM task_relations
|
|
WHERE task_id IN (`+inPlaceholders+`)
|
|
AND relation_kind = '`+string(RelationKindSubtask)+`'
|
|
|
|
UNION ALL
|
|
|
|
SELECT tr.task_id,
|
|
tr.other_task_id,
|
|
tr.relation_kind,
|
|
tr.created_by_id,
|
|
tr.created
|
|
FROM task_relations tr
|
|
INNER JOIN
|
|
sub_tasks st ON tr.task_id = st.other_task_id
|
|
WHERE tr.relation_kind = '`+string(RelationKindSubtask)+`')
|
|
SELECT other_task_id
|
|
FROM sub_tasks) AND id NOT IN (`+notIn+`)`, allArgs...).Find(&subtasks)
|
|
if err != nil {
|
|
return nil, totalCount, err
|
|
}
|
|
|
|
tasks = append(tasks, subtasks...)
|
|
}
|
|
|
|
queryCount := d.s.Where(cond)
|
|
if joinTaskBuckets {
|
|
joinCond := "task_buckets.task_id = tasks.id"
|
|
if opts.projectViewID > 0 {
|
|
joinCond += " AND task_buckets.project_view_id = ?"
|
|
queryCount = queryCount.Join("LEFT", "task_buckets", joinCond, opts.projectViewID)
|
|
} else {
|
|
queryCount = queryCount.Join("LEFT", "task_buckets", joinCond)
|
|
}
|
|
}
|
|
if expandSubtasks {
|
|
queryCount = queryCount.
|
|
Join("LEFT", "task_relations", "tasks.id = task_relations.task_id and task_relations.relation_kind = 'parenttask'").
|
|
Join("LEFT", "tasks parent_tasks", "task_relations.other_task_id = parent_tasks.id")
|
|
}
|
|
totalCount, err = queryCount.
|
|
Select("count(DISTINCT tasks.id)").
|
|
Count(&Task{})
|
|
if err != nil {
|
|
sql, vals := queryCount.LastSQL()
|
|
return nil, 0, fmt.Errorf("could not fetch task count, error was '%w', sql: '%v', values: %v", err, sql, vals)
|
|
}
|
|
return
|
|
}
|