api/pkg/models/task_collection_filter.go
konrad 0a1d8c9404 feat: add date math for filters (#1086)
This adds support for relative dates in filters, similar to the ones from [grafana](https://grafana.com/docs/grafana/latest/dashboards/time-range-controls) or [elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math).

In short, it allows you to filter for due dates by passing in dates like "now - 7d" to get a date from 7 days ago.

This is a very powerful addition for saved filters as they will allow you to create filters for all kinds of stuff where you previously only could use fixed dates. Now you can for example create a saved filter for "all tasks this week".

Frontend PR: vikunja/frontend#1342

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#1086
2022-03-27 20:35:04 +00:00

237 lines
6.9 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"fmt"
"reflect"
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/config"
"github.com/iancoleman/strcase"
"github.com/vectordotdev/go-datemath"
"xorm.io/xorm/schemas"
)
type taskFilterComparator string
const (
taskFilterComparatorInvalid taskFilterComparator = "invalid"
taskFilterComparatorEquals taskFilterComparator = "="
taskFilterComparatorGreater taskFilterComparator = ">"
taskFilterComparatorGreateEquals taskFilterComparator = ">="
taskFilterComparatorLess taskFilterComparator = "<"
taskFilterComparatorLessEquals taskFilterComparator = "<="
taskFilterComparatorNotEquals taskFilterComparator = "!="
taskFilterComparatorLike taskFilterComparator = "like"
taskFilterComparatorIn taskFilterComparator = "in"
)
type taskFilter struct {
field string
value interface{} // Needs to be an interface to be able to hold the field's native value
comparator taskFilterComparator
}
func getTaskFiltersByCollections(c *TaskCollection) (filters []*taskFilter, err error) {
if len(c.FilterByArr) > 0 {
c.FilterBy = append(c.FilterBy, c.FilterByArr...)
}
if len(c.FilterValueArr) > 0 {
c.FilterValue = append(c.FilterValue, c.FilterValueArr...)
}
if len(c.FilterComparatorArr) > 0 {
c.FilterComparator = append(c.FilterComparator, c.FilterComparatorArr...)
}
if c.FilterConcat != "" && c.FilterConcat != filterConcatAnd && c.FilterConcat != filterConcatOr {
return nil, ErrInvalidTaskFilterConcatinator{
Concatinator: taskFilterConcatinator(c.FilterConcat),
}
}
filters = make([]*taskFilter, 0, len(c.FilterBy))
for i, f := range c.FilterBy {
filter := &taskFilter{
field: f,
comparator: taskFilterComparatorEquals,
}
if len(c.FilterComparator) > i {
filter.comparator, err = getFilterComparatorFromString(c.FilterComparator[i])
if err != nil {
return
}
}
err = validateTaskFieldComparator(filter.comparator)
if err != nil {
return
}
// Cast the field value to its native type
if len(c.FilterValue) > i {
filter.value, err = getNativeValueForTaskField(filter.field, filter.comparator, c.FilterValue[i])
if err != nil {
return nil, ErrInvalidTaskFilterValue{
Value: filter.field,
Field: c.FilterValue[i],
}
}
}
filters = append(filters, filter)
}
return
}
func validateTaskFieldComparator(comparator taskFilterComparator) error {
switch comparator {
case
taskFilterComparatorEquals,
taskFilterComparatorGreater,
taskFilterComparatorGreateEquals,
taskFilterComparatorLess,
taskFilterComparatorLessEquals,
taskFilterComparatorNotEquals,
taskFilterComparatorLike,
taskFilterComparatorIn:
return nil
case taskFilterComparatorInvalid:
fallthrough
default:
return ErrInvalidTaskFilterComparator{Comparator: comparator}
}
}
func getFilterComparatorFromString(comparator string) (taskFilterComparator, error) {
switch comparator {
case "equals":
return taskFilterComparatorEquals, nil
case "greater":
return taskFilterComparatorGreater, nil
case "greater_equals":
return taskFilterComparatorGreateEquals, nil
case "less":
return taskFilterComparatorLess, nil
case "less_equals":
return taskFilterComparatorLessEquals, nil
case "not_equals":
return taskFilterComparatorNotEquals, nil
case "like":
return taskFilterComparatorLike, nil
case "in":
return taskFilterComparatorIn, nil
default:
return taskFilterComparatorInvalid, ErrInvalidTaskFilterComparator{Comparator: taskFilterComparator(comparator)}
}
}
func getValueForField(field reflect.StructField, rawValue string) (value interface{}, err error) {
switch field.Type.Kind() {
case reflect.Int64:
value, err = strconv.ParseInt(rawValue, 10, 64)
case reflect.Float64:
value, err = strconv.ParseFloat(rawValue, 64)
case reflect.String:
value = rawValue
case reflect.Bool:
value, err = strconv.ParseBool(rawValue)
case reflect.Struct:
if field.Type == schemas.TimeType {
var t datemath.Expression
t, err = datemath.Parse(rawValue)
if err == nil {
value = t.Time(datemath.WithLocation(config.GetTimeZone()))
} else {
value, err = time.Parse(time.RFC3339, rawValue)
value = value.(time.Time).In(config.GetTimeZone())
}
}
case reflect.Slice:
// If this is a slice of pointers we're dealing with some property which is a relation
// In that case we don't really care about what the actual type is, we just cast the value to an
// int64 since we need the id - yes, this assumes we only ever have int64 IDs, but this is fine.
if field.Type.Elem().Kind() == reflect.Ptr {
value, err = strconv.ParseInt(rawValue, 10, 64)
return
}
// There are probably better ways to do this - please let me know if you have one.
if field.Type.Elem().String() == "time.Time" {
value, err = time.Parse(time.RFC3339, rawValue)
value = value.(time.Time).In(config.GetTimeZone())
return
}
fallthrough
default:
panic(fmt.Errorf("unrecognized filter type %s for field %s, value %s", field.Type.String(), field.Name, value))
}
return
}
func getNativeValueForTaskField(fieldName string, comparator taskFilterComparator, value string) (nativeValue interface{}, err error) {
realFieldName := strings.ReplaceAll(strcase.ToCamel(fieldName), "Id", "ID")
if realFieldName == "Namespace" {
if comparator == taskFilterComparatorIn {
vals := strings.Split(value, ",")
valueSlice := []interface{}{}
for _, val := range vals {
v, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return nil, err
}
valueSlice = append(valueSlice, v)
}
return valueSlice, nil
}
nativeValue, err = strconv.ParseInt(value, 10, 64)
return
}
field, ok := reflect.TypeOf(&Task{}).Elem().FieldByName(realFieldName)
if !ok {
return nil, ErrInvalidTaskField{TaskField: fieldName}
}
if comparator == taskFilterComparatorIn {
vals := strings.Split(value, ",")
valueSlice := []interface{}{}
for _, val := range vals {
v, err := getValueForField(field, val)
if err != nil {
return nil, err
}
valueSlice = append(valueSlice, v)
}
return valueSlice, nil
}
return getValueForField(field, value)
}