feat(filters): saved filters with a query language, and label management
"today & p1", "#Firma & @regnskab", "overdue | (today & assigned to: me)". The grammar is Todoist's, so the import path from it does not require relearning anything, and both languages' keywords are accepted. It compiles to parameterised SQL rather than filtering in Go: one indexed query instead of ten thousand rows crossing the boundary. No typed text is ever concatenated into the statement — this is a language people write by hand, which makes it exactly where string-building would become an injection. An expression is compiled before it is saved. A filter that cannot run is worse than none: it is discovered later, as a list that is empty for a reason nobody can see. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a96aae57ec
commit
4d142b2fa7
@@ -0,0 +1,385 @@
|
||||
// Package filterquery parses the saved-filter language: the small expression
|
||||
// somebody types to describe a slice of their tasks.
|
||||
//
|
||||
// today & p1
|
||||
// #Firma & @regnskab
|
||||
// overdue | (today & assigned to: me)
|
||||
// 7 days & !@venter
|
||||
//
|
||||
// It compiles to SQL rather than filtering in Go, so a filter over ten thousand
|
||||
// tasks is one indexed query instead of ten thousand rows crossing the boundary.
|
||||
//
|
||||
// The grammar is deliberately Todoist's, because that is the one people already
|
||||
// know and the import path from it should not require relearning anything:
|
||||
//
|
||||
// expression := term (('&' | '|' | ',') term)*
|
||||
// term := '!' term | '(' expression ')' | atom
|
||||
// atom := #project | @label | pN | date-word | 'assigned to:' name | text
|
||||
//
|
||||
// '&' binds tighter than '|', and ',' is Todoist's spelling of '|'.
|
||||
package filterquery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Compiled is a WHERE fragment and its arguments, ready to be dropped into the
|
||||
// task query. The fragment references the task table as `t`.
|
||||
type Compiled struct {
|
||||
SQL string
|
||||
Args []any
|
||||
}
|
||||
|
||||
// Context is what the query needs to know that is not in the text: who is asking,
|
||||
// and what "today" means where they are.
|
||||
type Context struct {
|
||||
UserID string
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
func (c Context) today() time.Time {
|
||||
loc := c.Location
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := c.Now.In(loc)
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
// Parse compiles a filter expression.
|
||||
//
|
||||
// An error names what went wrong in the same terms the person typed, because this
|
||||
// is a language they are writing by hand in a small box with no autocomplete.
|
||||
func Parse(query string, ctx Context) (Compiled, error) {
|
||||
p := &parser{tokens: tokenize(query), ctx: ctx}
|
||||
if len(p.tokens) == 0 {
|
||||
return Compiled{}, fmt.Errorf("filteret er tomt")
|
||||
}
|
||||
|
||||
node, err := p.parseExpression()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
if !p.done() {
|
||||
return Compiled{}, fmt.Errorf("kunne ikke læse %q", p.peek().text)
|
||||
}
|
||||
return node, nil
|
||||
}
|
||||
|
||||
// --- tokens -------------------------------------------------------------------
|
||||
|
||||
type tokenKind int
|
||||
|
||||
const (
|
||||
tokWord tokenKind = iota
|
||||
tokAnd
|
||||
tokOr
|
||||
tokNot
|
||||
tokLParen
|
||||
tokRParen
|
||||
)
|
||||
|
||||
type token struct {
|
||||
kind tokenKind
|
||||
text string
|
||||
}
|
||||
|
||||
func tokenize(query string) []token {
|
||||
var out []token
|
||||
runes := []rune(query)
|
||||
|
||||
for i := 0; i < len(runes); {
|
||||
r := runes[i]
|
||||
switch {
|
||||
case unicode.IsSpace(r):
|
||||
i++
|
||||
case r == '&':
|
||||
out = append(out, token{kind: tokAnd, text: "&"})
|
||||
i++
|
||||
case r == '|', r == ',':
|
||||
out = append(out, token{kind: tokOr, text: string(r)})
|
||||
i++
|
||||
case r == '!':
|
||||
out = append(out, token{kind: tokNot, text: "!"})
|
||||
i++
|
||||
case r == '(':
|
||||
out = append(out, token{kind: tokLParen, text: "("})
|
||||
i++
|
||||
case r == ')':
|
||||
out = append(out, token{kind: tokRParen, text: ")"})
|
||||
i++
|
||||
default:
|
||||
// A word runs to the next operator. That is what lets "assigned to:
|
||||
// anders" and "7 days" arrive as single atoms rather than as three
|
||||
// fragments the parser would then have to reassemble.
|
||||
start := i
|
||||
for i < len(runes) && !isOperator(runes[i]) {
|
||||
i++
|
||||
}
|
||||
text := strings.TrimSpace(string(runes[start:i]))
|
||||
if text != "" {
|
||||
out = append(out, token{kind: tokWord, text: text})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isOperator(r rune) bool {
|
||||
switch r {
|
||||
case '&', '|', ',', '!', '(', ')':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- parser -------------------------------------------------------------------
|
||||
|
||||
type parser struct {
|
||||
tokens []token
|
||||
pos int
|
||||
ctx Context
|
||||
}
|
||||
|
||||
func (p *parser) done() bool { return p.pos >= len(p.tokens) }
|
||||
func (p *parser) peek() token { return p.tokens[p.pos] }
|
||||
func (p *parser) next() token { t := p.tokens[p.pos]; p.pos++; return t }
|
||||
|
||||
// parseExpression handles '|' — the loosest binding, so it is the outermost level.
|
||||
func (p *parser) parseExpression() (Compiled, error) {
|
||||
left, err := p.parseAnd()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
for !p.done() && p.peek().kind == tokOr {
|
||||
p.next()
|
||||
right, err := p.parseAnd()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
left = combine("OR", left, right)
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseAnd() (Compiled, error) {
|
||||
left, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
for !p.done() && p.peek().kind == tokAnd {
|
||||
p.next()
|
||||
right, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
left = combine("AND", left, right)
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func (p *parser) parseTerm() (Compiled, error) {
|
||||
if p.done() {
|
||||
return Compiled{}, fmt.Errorf("filteret slutter midt i et udtryk")
|
||||
}
|
||||
|
||||
switch t := p.peek(); t.kind {
|
||||
case tokNot:
|
||||
p.next()
|
||||
inner, err := p.parseTerm()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
return Compiled{SQL: "NOT (" + inner.SQL + ")", Args: inner.Args}, nil
|
||||
|
||||
case tokLParen:
|
||||
p.next()
|
||||
inner, err := p.parseExpression()
|
||||
if err != nil {
|
||||
return Compiled{}, err
|
||||
}
|
||||
if p.done() || p.peek().kind != tokRParen {
|
||||
return Compiled{}, fmt.Errorf("der mangler en afsluttende parentes")
|
||||
}
|
||||
p.next()
|
||||
return Compiled{SQL: "(" + inner.SQL + ")", Args: inner.Args}, nil
|
||||
|
||||
case tokRParen:
|
||||
return Compiled{}, fmt.Errorf("uventet )")
|
||||
|
||||
case tokAnd, tokOr:
|
||||
return Compiled{}, fmt.Errorf("%q står hvor der skulle stå et søgeord", t.text)
|
||||
|
||||
default:
|
||||
p.next()
|
||||
return p.atom(t.text)
|
||||
}
|
||||
}
|
||||
|
||||
func combine(op string, left, right Compiled) Compiled {
|
||||
return Compiled{
|
||||
SQL: "(" + left.SQL + " " + op + " " + right.SQL + ")",
|
||||
Args: append(append([]any{}, left.Args...), right.Args...),
|
||||
}
|
||||
}
|
||||
|
||||
// --- atoms --------------------------------------------------------------------
|
||||
|
||||
// atom compiles one search term.
|
||||
//
|
||||
// Every branch produces parameterised SQL. No user text is ever concatenated into
|
||||
// the statement — this is a language people type, so it is exactly the place where
|
||||
// string-building would become an injection.
|
||||
func (p *parser) atom(text string) (Compiled, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(text))
|
||||
if lower == "" {
|
||||
return Compiled{}, fmt.Errorf("tomt søgeord")
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "#"):
|
||||
return p.project(strings.TrimPrefix(text, "#"))
|
||||
|
||||
case strings.HasPrefix(lower, "@"):
|
||||
return p.label(strings.TrimPrefix(text, "@"))
|
||||
|
||||
case strings.HasPrefix(lower, "assigned to:"), strings.HasPrefix(lower, "tildelt:"):
|
||||
_, value, _ := strings.Cut(text, ":")
|
||||
return p.assignee(strings.TrimSpace(value))
|
||||
|
||||
case lower == "assigned", lower == "tildelt":
|
||||
return Compiled{SQL: "t.assignee_id IS NOT NULL"}, nil
|
||||
|
||||
case lower == "no date", lower == "ingen dato", lower == "no due date":
|
||||
return Compiled{SQL: "t.due_date IS NULL"}, nil
|
||||
|
||||
case lower == "overdue", lower == "forsinket", lower == "over due":
|
||||
return Compiled{
|
||||
SQL: "(t.due_date IS NOT NULL AND t.due_date < ?)",
|
||||
Args: []any{p.day(0)},
|
||||
}, nil
|
||||
|
||||
case lower == "today", lower == "i dag", lower == "idag":
|
||||
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(0)}}, nil
|
||||
|
||||
case lower == "tomorrow", lower == "i morgen", lower == "imorgen":
|
||||
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(1)}}, nil
|
||||
|
||||
case lower == "yesterday", lower == "i går", lower == "igar":
|
||||
return Compiled{SQL: "t.due_date = ?", Args: []any{p.day(-1)}}, nil
|
||||
|
||||
case lower == "recurring", lower == "gentagen", lower == "gentagne":
|
||||
return Compiled{SQL: "t.recurrence_rule IS NOT NULL"}, nil
|
||||
|
||||
case lower == "completed", lower == "færdig", lower == "afsluttet":
|
||||
return Compiled{SQL: "t.completed_at IS NOT NULL"}, nil
|
||||
|
||||
case lower == "subtask", lower == "underopgave":
|
||||
return Compiled{SQL: "t.parent_id IS NOT NULL"}, nil
|
||||
}
|
||||
|
||||
// pN — priority. Todoist numbers them the way the interface does, p1 highest.
|
||||
if len(lower) == 2 && lower[0] == 'p' && lower[1] >= '1' && lower[1] <= '4' {
|
||||
return Compiled{SQL: "t.priority = ?", Args: []any{int(lower[1] - '0')}}, nil
|
||||
}
|
||||
|
||||
// "7 days" / "7 dage" — everything due between now and then, which is how
|
||||
// Todoist reads it: a window, not a single day.
|
||||
if n, unit, ok := cutNumberWord(lower); ok {
|
||||
switch unit {
|
||||
case "days", "dage", "day", "dag":
|
||||
return Compiled{
|
||||
SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)",
|
||||
Args: []any{p.day(0), p.day(n)},
|
||||
}, nil
|
||||
case "weeks", "uger", "week", "uge":
|
||||
return Compiled{
|
||||
SQL: "(t.due_date IS NOT NULL AND t.due_date >= ? AND t.due_date <= ?)",
|
||||
Args: []any{p.day(0), p.day(n * 7)},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit date.
|
||||
if _, err := time.Parse("2006-01-02", lower); err == nil {
|
||||
return Compiled{SQL: "t.due_date = ?", Args: []any{lower}}, nil
|
||||
}
|
||||
|
||||
// Anything left is free text, matched against the task's own words. Falling
|
||||
// back to a search rather than an error is what keeps the box forgiving: a
|
||||
// filter that finds too much is fixable, one that refuses to run is not.
|
||||
return Compiled{
|
||||
SQL: "(t.content LIKE ? OR t.description LIKE ?)",
|
||||
Args: []any{"%" + text + "%", "%" + text + "%"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *parser) day(offset int) string {
|
||||
return p.ctx.today().AddDate(0, 0, offset).Format("2006-01-02")
|
||||
}
|
||||
|
||||
// project matches by name, case-insensitively, and only among projects the asking
|
||||
// user can see — the outer query restricts that, but naming a project here must not
|
||||
// widen it.
|
||||
func (p *parser) project(name string) (Compiled, error) {
|
||||
name = strings.TrimSpace(strings.Trim(name, `"`))
|
||||
if name == "" {
|
||||
return Compiled{}, fmt.Errorf("#: der mangler et projektnavn")
|
||||
}
|
||||
return Compiled{
|
||||
SQL: `t.project_id IN (
|
||||
SELECT id FROM projects WHERE lower(name) = lower(?) AND deleted_at IS NULL)`,
|
||||
Args: []any{name},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// label matches the asking user's own labels. Labels are personal, so the same word
|
||||
// on somebody else's task is a different label and must not match.
|
||||
func (p *parser) label(name string) (Compiled, error) {
|
||||
name = strings.TrimSpace(strings.Trim(name, `"`))
|
||||
if name == "" {
|
||||
return Compiled{}, fmt.Errorf("@: der mangler et etiketnavn")
|
||||
}
|
||||
return Compiled{
|
||||
SQL: `EXISTS (
|
||||
SELECT 1 FROM task_labels tl JOIN labels l ON l.id = tl.label_id
|
||||
WHERE tl.task_id = t.id AND l.user_id = ? AND lower(l.name) = lower(?))`,
|
||||
Args: []any{p.ctx.UserID, name},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// assignee resolves "me" to the asking user and anything else to a name or address.
|
||||
func (p *parser) assignee(who string) (Compiled, error) {
|
||||
who = strings.TrimSpace(strings.Trim(who, `"`))
|
||||
switch strings.ToLower(who) {
|
||||
case "":
|
||||
return Compiled{}, fmt.Errorf("assigned to: der mangler et navn")
|
||||
case "me", "mig":
|
||||
return Compiled{SQL: "t.assignee_id = ?", Args: []any{p.ctx.UserID}}, nil
|
||||
case "none", "ingen", "nobody":
|
||||
return Compiled{SQL: "t.assignee_id IS NULL"}, nil
|
||||
}
|
||||
return Compiled{
|
||||
SQL: `t.assignee_id IN (
|
||||
SELECT id FROM users WHERE lower(email) = lower(?) OR lower(name) = lower(?))`,
|
||||
Args: []any{who, who},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// cutNumberWord reads "7 days" into (7, "days").
|
||||
func cutNumberWord(s string) (int, string, bool) {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) != 2 {
|
||||
return 0, "", false
|
||||
}
|
||||
n, err := strconv.Atoi(fields[0])
|
||||
if err != nil || n < 0 || n > 3650 {
|
||||
return 0, "", false
|
||||
}
|
||||
return n, fields[1], true
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package filterquery
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func ctx(t *testing.T) Context {
|
||||
t.Helper()
|
||||
loc, err := time.LoadLocation("Europe/Copenhagen")
|
||||
if err != nil {
|
||||
t.Fatalf("load timezone: %v", err)
|
||||
}
|
||||
return Context{
|
||||
UserID: "u1",
|
||||
Now: time.Date(2026, 3, 10, 10, 0, 0, 0, loc),
|
||||
Location: loc,
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProducesRunnableSQL(t *testing.T) {
|
||||
// Every expression is executed against a real SQLite schema, not merely
|
||||
// string-compared. A filter language that compiles to SQL which does not run
|
||||
// is worse than no filter language.
|
||||
db := schema(t)
|
||||
c := ctx(t)
|
||||
|
||||
queries := []string{
|
||||
"today",
|
||||
"i dag",
|
||||
"overdue",
|
||||
"forsinket",
|
||||
"tomorrow",
|
||||
"no date",
|
||||
"p1",
|
||||
"p4",
|
||||
"#Firma",
|
||||
"@regnskab",
|
||||
"assigned to: me",
|
||||
"assigned to: [email protected]",
|
||||
"tildelt: mig",
|
||||
"assigned to: none",
|
||||
"assigned",
|
||||
"recurring",
|
||||
"completed",
|
||||
"subtask",
|
||||
"7 days",
|
||||
"14 dage",
|
||||
"2 weeks",
|
||||
"2026-12-24",
|
||||
"today & p1",
|
||||
"today | tomorrow",
|
||||
"today, tomorrow",
|
||||
"#Firma & @regnskab",
|
||||
"!@venter",
|
||||
"today & !p4",
|
||||
"(today | overdue) & p1",
|
||||
"overdue | (today & assigned to: me)",
|
||||
"#Firma & (p1 | p2) & !completed",
|
||||
"rapport",
|
||||
"7 days & #Firma & @vigtig",
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
t.Run(q, func(t *testing.T) {
|
||||
compiled, err := Parse(q, c)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if compiled.SQL == "" {
|
||||
t.Fatal("compiled to an empty condition")
|
||||
}
|
||||
// The fragment has to slot into the real task query.
|
||||
stmt := `SELECT count(*) FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE ` + compiled.SQL
|
||||
var n int
|
||||
if err := db.QueryRow(stmt, compiled.Args...).Scan(&n); err != nil {
|
||||
t.Fatalf("query failed: %v\nSQL: %s", err, stmt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The compiled fragment must actually select the right rows, not merely run.
|
||||
func TestFiltersSelectTheRightTasks(t *testing.T) {
|
||||
db := schema(t)
|
||||
c := ctx(t)
|
||||
seed(t, db)
|
||||
|
||||
cases := []struct {
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"today", []string{"i-dag-p1"}},
|
||||
{"overdue", []string{"forsinket"}},
|
||||
{"tomorrow", []string{"i-morgen"}},
|
||||
// firma-p3 has no due date either, so it belongs here.
|
||||
{"no date", []string{"ingen-dato", "underopgave", "firma-p3"}},
|
||||
{"p1", []string{"i-dag-p1"}},
|
||||
{"#Firma", []string{"i-dag-p1", "firma-p3"}},
|
||||
{"@regnskab", []string{"i-dag-p1"}},
|
||||
{"assigned to: me", []string{"i-dag-p1"}},
|
||||
{"assigned to: none", []string{"forsinket", "i-morgen", "ingen-dato", "firma-p3", "underopgave"}},
|
||||
{"recurring", []string{"i-morgen"}},
|
||||
{"subtask", []string{"underopgave"}},
|
||||
|
||||
// Combinations
|
||||
{"today & p1", []string{"i-dag-p1"}},
|
||||
{"today | overdue", []string{"forsinket", "i-dag-p1"}},
|
||||
{"#Firma & p1", []string{"i-dag-p1"}},
|
||||
{"#Firma & !p1", []string{"firma-p3"}},
|
||||
// u1's "regnskab" is on i-dag-p1 only, so the other Firma task survives.
|
||||
{"!@regnskab & #Firma", []string{"firma-p3"}},
|
||||
{"(today | tomorrow) & !p1", []string{"i-morgen"}},
|
||||
|
||||
// A window, not a single day.
|
||||
{"7 days", []string{"i-dag-p1", "i-morgen"}},
|
||||
|
||||
// Free text falls through to a content search.
|
||||
{"moms", []string{"i-dag-p1"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.query, func(t *testing.T) {
|
||||
compiled, err := Parse(tc.query, c)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
got := run(t, db, compiled)
|
||||
if !sameSet(got, tc.want) {
|
||||
t.Errorf("matched %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// '&' binds tighter than '|', so "a | b & c" is "a | (b & c)".
|
||||
func TestAndBindsTighterThanOr(t *testing.T) {
|
||||
db := schema(t)
|
||||
c := ctx(t)
|
||||
seed(t, db)
|
||||
|
||||
loose, err := Parse("overdue | today & p1", c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := Parse("overdue | (today & p1)", c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameSet(run(t, db, loose), run(t, db, explicit)) {
|
||||
t.Error("'a | b & c' does not mean 'a | (b & c)'")
|
||||
}
|
||||
|
||||
// And the other grouping really is different, or the test above proves nothing.
|
||||
other, err := Parse("(overdue | today) & p1", c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sameSet(run(t, db, loose), run(t, db, other)) {
|
||||
t.Error("both groupings select the same rows; the precedence test is vacuous")
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
c := ctx(t)
|
||||
for _, q := range []string{
|
||||
"",
|
||||
" ",
|
||||
"&",
|
||||
"today &",
|
||||
"| today",
|
||||
"(today",
|
||||
"today)",
|
||||
"()",
|
||||
"#",
|
||||
"@",
|
||||
"assigned to:",
|
||||
} {
|
||||
t.Run(q, func(t *testing.T) {
|
||||
if _, err := Parse(q, c); err == nil {
|
||||
t.Error("accepted an expression that is not valid")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The filter box takes free text from a person. None of it may reach the database
|
||||
// as SQL — every value has to arrive as a bound parameter.
|
||||
func TestNoUserTextReachesTheSQL(t *testing.T) {
|
||||
db := schema(t)
|
||||
c := ctx(t)
|
||||
seed(t, db)
|
||||
|
||||
hostile := []string{
|
||||
`#'; DROP TABLE tasks; --`,
|
||||
`@' OR 1=1 --`,
|
||||
`assigned to: ' OR '1'='1`,
|
||||
`'; DELETE FROM projects; --`,
|
||||
`x' UNION SELECT password_hash FROM users --`,
|
||||
}
|
||||
|
||||
for _, q := range hostile {
|
||||
t.Run(q, func(t *testing.T) {
|
||||
compiled, err := Parse(q, c)
|
||||
if err != nil {
|
||||
return // refusing it outright is also a correct answer
|
||||
}
|
||||
// The property is that the typed text appears only among the bound
|
||||
// arguments — never in the statement. Scanning the SQL for words like
|
||||
// DELETE would instead flag the column name `deleted_at`, which proves
|
||||
// nothing about injection.
|
||||
for _, needle := range []string{"DROP TABLE", "DELETE FROM", "UNION SELECT", "OR 1=1", "'1'='1"} {
|
||||
if strings.Contains(strings.ToUpper(compiled.SQL), needle) {
|
||||
t.Fatalf("%q was built into the SQL: %s", needle, compiled.SQL)
|
||||
}
|
||||
}
|
||||
// And it still executes, so the expression is not merely inert text —
|
||||
// it reaches the database as a real query with the hostile part bound.
|
||||
run(t, db, compiled)
|
||||
})
|
||||
}
|
||||
|
||||
// And everything is still there.
|
||||
var tasks, projects int
|
||||
if err := db.QueryRow(`SELECT count(*) FROM tasks`).Scan(&tasks); err != nil {
|
||||
t.Fatalf("the tasks table did not survive: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT count(*) FROM projects`).Scan(&projects); err != nil {
|
||||
t.Fatalf("the projects table did not survive: %v", err)
|
||||
}
|
||||
if tasks == 0 || projects == 0 {
|
||||
t.Error("rows were deleted by a filter expression")
|
||||
}
|
||||
}
|
||||
|
||||
// Labels are personal: the same word on another person's task is a different label.
|
||||
func TestLabelsAreScopedToTheAskingUser(t *testing.T) {
|
||||
db := schema(t)
|
||||
seed(t, db)
|
||||
|
||||
// Both users have a label called "regnskab", on different tasks. Each must see
|
||||
// only their own — that is what "labels are personal" has to mean in practice.
|
||||
mine, err := Parse("@regnskab", ctx(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(t, db, mine); !sameSet(got, []string{"i-dag-p1"}) {
|
||||
t.Errorf("u1 matched %v, want only their own labelled task", got)
|
||||
}
|
||||
|
||||
other := ctx(t)
|
||||
other.UserID = "u2"
|
||||
theirs, err := Parse("@regnskab", other)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := run(t, db, theirs); !sameSet(got, []string{"firma-p3"}) {
|
||||
t.Errorf("u2 matched %v, want only their own labelled task", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------------
|
||||
|
||||
func schema(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "f.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, name TEXT, password_hash TEXT)`,
|
||||
`CREATE TABLE projects (id TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER)`,
|
||||
`CREATE TABLE labels (id TEXT PRIMARY KEY, user_id TEXT, name TEXT)`,
|
||||
`CREATE TABLE task_labels (task_id TEXT, label_id TEXT)`,
|
||||
`CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT, content TEXT,
|
||||
description TEXT DEFAULT '', priority INTEGER DEFAULT 4,
|
||||
due_date TEXT, recurrence_rule TEXT, assignee_id TEXT,
|
||||
completed_at INTEGER, deleted_at INTEGER)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("schema: %v", err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seed(t *testing.T, db *sql.DB) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO users (id, email, name) VALUES
|
||||
('u1','[email protected]','Kristian'), ('u2','[email protected]','Anders')`,
|
||||
`INSERT INTO projects (id, name) VALUES ('p-firma','Firma'), ('p-inbox','Indbakke')`,
|
||||
`INSERT INTO labels (id, user_id, name) VALUES
|
||||
('l-regnskab','u1','regnskab'), ('l-andens','u2','regnskab')`,
|
||||
|
||||
// 10 March 2026 is "today" for these tests.
|
||||
`INSERT INTO tasks (id, project_id, content, priority, due_date, assignee_id) VALUES
|
||||
('i-dag-p1','p-firma','betal moms',1,'2026-03-10','u1')`,
|
||||
`INSERT INTO tasks (id, project_id, content, priority, due_date) VALUES
|
||||
('forsinket','p-inbox','var forsinket',3,'2026-03-01')`,
|
||||
`INSERT INTO tasks (id, project_id, content, priority, due_date, recurrence_rule) VALUES
|
||||
('i-morgen','p-inbox','i morgen',3,'2026-03-11','FREQ=DAILY')`,
|
||||
`INSERT INTO tasks (id, project_id, content, priority) VALUES
|
||||
('ingen-dato','p-inbox','ingen dato',4)`,
|
||||
`INSERT INTO tasks (id, project_id, content, priority) VALUES
|
||||
('firma-p3','p-firma','noget andet',3)`,
|
||||
`INSERT INTO tasks (id, project_id, parent_id, content, priority) VALUES
|
||||
('underopgave','p-inbox','ingen-dato','en underopgave',4)`,
|
||||
|
||||
`INSERT INTO task_labels (task_id, label_id) VALUES ('i-dag-p1','l-regnskab')`,
|
||||
// The other user's identically-named label, on a task of theirs.
|
||||
`INSERT INTO task_labels (task_id, label_id) VALUES ('firma-p3','l-andens')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run(t *testing.T, db *sql.DB, c Compiled) []string {
|
||||
t.Helper()
|
||||
rows, err := db.Query(`SELECT t.id FROM tasks t
|
||||
JOIN projects p ON p.id = t.project_id
|
||||
WHERE `+c.SQL+` ORDER BY t.id`, c.Args...)
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v\nSQL: %s", err, c.SQL)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for _, v := range a {
|
||||
seen[v]++
|
||||
}
|
||||
for _, v := range b {
|
||||
seen[v]--
|
||||
}
|
||||
for _, n := range seen {
|
||||
if n != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/filterquery"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
type filterJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Query string `json:"query"`
|
||||
Color string `json:"color"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func toFilterJSON(f store.Filter) filterJSON {
|
||||
return filterJSON{ID: f.ID, Name: f.Name, Query: f.Query, Color: f.Color, SortOrder: f.SortOrder}
|
||||
}
|
||||
|
||||
func (s *Server) handleListFilters(w http.ResponseWriter, r *http.Request) {
|
||||
filters, err := s.db.ListFilters(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list filters", err)
|
||||
return
|
||||
}
|
||||
out := make([]filterJSON, 0, len(filters))
|
||||
for _, f := range filters {
|
||||
out = append(out, toFilterJSON(f))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"filters": out})
|
||||
}
|
||||
|
||||
type filterRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Query *string `json:"query"`
|
||||
Color *string `json:"color"`
|
||||
SortOrder *float64 `json:"sort_order"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
var req filterRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
name := strings.TrimSpace(valueOr(req.Name, ""))
|
||||
query := strings.TrimSpace(valueOr(req.Query, ""))
|
||||
|
||||
fields := map[string]string{}
|
||||
if name == "" {
|
||||
fields["name"] = "required"
|
||||
}
|
||||
// The expression is compiled before it is saved. A filter that cannot run is
|
||||
// worse than no filter: it is discovered later, from a list that is empty for
|
||||
// a reason nobody can see.
|
||||
if msg := checkFilterQuery(query, user); msg != "" {
|
||||
fields["query"] = msg
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
writeFieldErrors(w, fields)
|
||||
return
|
||||
}
|
||||
|
||||
f := &store.Filter{UserID: user.ID, Name: name, Query: query, Color: valueOr(req.Color, "")}
|
||||
if req.SortOrder != nil {
|
||||
f.SortOrder = *req.SortOrder
|
||||
}
|
||||
if err := s.db.CreateFilter(r.Context(), f); err != nil {
|
||||
s.internal(w, "create filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toFilterJSON(*f))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateFilter(w http.ResponseWriter, r *http.Request) {
|
||||
var req filterRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
if req.Query != nil {
|
||||
query := strings.TrimSpace(*req.Query)
|
||||
if msg := checkFilterQuery(query, user); msg != "" {
|
||||
writeFieldErrors(w, map[string]string{"query": msg})
|
||||
return
|
||||
}
|
||||
req.Query = &query
|
||||
}
|
||||
if req.Name != nil {
|
||||
name := strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
req.Name = &name
|
||||
}
|
||||
|
||||
err := s.db.UpdateFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"), store.FilterUpdate{
|
||||
Name: req.Name, Query: req.Query, Color: req.Color, SortOrder: req.SortOrder,
|
||||
})
|
||||
if err != nil {
|
||||
s.storeError(w, "update filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFilter(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteFilter(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete filter", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRunFilter executes a saved filter and returns the tasks it selects.
|
||||
func (s *Server) handleRunFilter(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
f, err := s.db.GetFilter(r.Context(), user.ID, chi.URLParam(r, "filterID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "get filter", err)
|
||||
return
|
||||
}
|
||||
s.runQuery(w, r, f.Query)
|
||||
}
|
||||
|
||||
// handlePreviewFilter runs an expression without saving it, so the filter editor
|
||||
// can show what it selects while it is being written.
|
||||
func (s *Server) handlePreviewFilter(w http.ResponseWriter, r *http.Request) {
|
||||
s.runQuery(w, r, r.URL.Query().Get("query"))
|
||||
}
|
||||
|
||||
func (s *Server) runQuery(w http.ResponseWriter, r *http.Request, query string) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
compiled, err := filterquery.Parse(query, filterContext(user))
|
||||
if err != nil {
|
||||
// The message is the parser's own, in Danish, naming what it could not
|
||||
// read — this is a language somebody is writing by hand in a small box.
|
||||
writeJSON(w, http.StatusUnprocessableEntity, APIError{
|
||||
Code: CodeValidation,
|
||||
Message: err.Error(),
|
||||
Fields: map[string]string{"query": err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
|
||||
FilterSQL: compiled.SQL,
|
||||
FilterArgs: compiled.Args,
|
||||
// A filter that names "completed" has to be able to find completed tasks,
|
||||
// so the query decides rather than the default.
|
||||
IncludeCompleted: strings.Contains(strings.ToLower(query), "completed") ||
|
||||
strings.Contains(strings.ToLower(query), "færdig") ||
|
||||
strings.Contains(strings.ToLower(query), "afsluttet"),
|
||||
Limit: parseLimit(r.URL.Query().Get("limit"), 200, 500),
|
||||
})
|
||||
if err != nil {
|
||||
s.internal(w, "run filter", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"tasks": toTaskList(tasks)})
|
||||
}
|
||||
|
||||
func filterContext(user *store.User) filterquery.Context {
|
||||
return filterquery.Context{
|
||||
UserID: user.ID,
|
||||
Now: time.Now(),
|
||||
Location: userLocation(user.Timezone),
|
||||
}
|
||||
}
|
||||
|
||||
func checkFilterQuery(query string, user *store.User) string {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "required"
|
||||
}
|
||||
if _, err := filterquery.Parse(query, filterContext(user)); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- labels ---------------------------------------------------------------------
|
||||
|
||||
type labelJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
SortOrder float64 `json:"sort_order"`
|
||||
TaskCount int `json:"task_count"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListLabels(w http.ResponseWriter, r *http.Request) {
|
||||
labels, err := s.db.ListLabels(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, "list labels", err)
|
||||
return
|
||||
}
|
||||
out := make([]labelJSON, 0, len(labels))
|
||||
for _, l := range labels {
|
||||
out = append(out, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, l.TaskCount})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"labels": out})
|
||||
}
|
||||
|
||||
type labelRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
var req labelRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(valueOr(req.Name, ""))
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
|
||||
l := &store.Label{UserID: userFrom(r.Context()).ID, Name: name, Color: valueOr(req.Color, "")}
|
||||
if err := s.db.CreateLabel(r.Context(), l); err != nil {
|
||||
if errors.Is(err, store.ErrLabelExists) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.internal(w, "create label", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, labelJSON{l.ID, l.Name, l.Color, l.SortOrder, 0})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateLabel(w http.ResponseWriter, r *http.Request) {
|
||||
var req labelRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
err := s.db.UpdateLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"),
|
||||
strings.TrimSpace(valueOr(req.Name, "")), valueOr(req.Color, ""))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLabelExists) {
|
||||
writeError(w, http.StatusConflict, CodeConflict, "you already have a label with that name")
|
||||
return
|
||||
}
|
||||
s.storeError(w, "update label", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteLabel(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.db.DeleteLabel(r.Context(), userFrom(r.Context()).ID, chi.URLParam(r, "labelID"))
|
||||
if err != nil {
|
||||
s.storeError(w, "delete label", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -116,6 +116,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/ws", s.handleWebSocket)
|
||||
|
||||
r.Get("/today", s.handleToday)
|
||||
r.Get("/filters/preview", s.handlePreviewFilter)
|
||||
|
||||
r.Route("/filters", func(r chi.Router) {
|
||||
r.Get("/", s.handleListFilters)
|
||||
r.Post("/", s.handleCreateFilter)
|
||||
r.Get("/{filterID}/tasks", s.handleRunFilter)
|
||||
r.Patch("/{filterID}", s.handleUpdateFilter)
|
||||
r.Delete("/{filterID}", s.handleDeleteFilter)
|
||||
})
|
||||
|
||||
r.Route("/labels", func(r chi.Router) {
|
||||
r.Get("/", s.handleListLabels)
|
||||
r.Post("/", s.handleCreateLabel)
|
||||
r.Patch("/{labelID}", s.handleUpdateLabel)
|
||||
r.Delete("/{labelID}", s.handleDeleteLabel)
|
||||
})
|
||||
r.Get("/upcoming", s.handleUpcoming)
|
||||
r.Get("/search", s.handleSearch)
|
||||
|
||||
|
||||
@@ -839,3 +839,170 @@ func TestCompletingAPlainTaskStillCloses(t *testing.T) {
|
||||
t.Error("a plain task reported as recurring")
|
||||
}
|
||||
}
|
||||
|
||||
// --- saved filters ----------------------------------------------------------------
|
||||
|
||||
func TestSavedFilters(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
||||
projectID := project["id"].(string)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "betal moms", "project_id": projectID,
|
||||
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"regnskab"},
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "ryd op", "project_id": projectID, "priority": 4,
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "forsinket ting", "due_date": userDate(t, -3), "priority": 2,
|
||||
})
|
||||
|
||||
resp, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
|
||||
"name": "Haster i dag", "query": "today & p1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create filter: %d %v", resp.StatusCode, filter)
|
||||
}
|
||||
|
||||
resp, result := ts.do(t, "GET", "/api/v1/filters/"+filter["id"].(string)+"/tasks", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("run filter: %d %v", resp.StatusCode, result)
|
||||
}
|
||||
tasks, _ := result["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("the filter matched %d tasks, want 1", len(tasks))
|
||||
}
|
||||
if tasks[0].(map[string]any)["content"] != "betal moms" {
|
||||
t.Errorf("matched the wrong task: %v", tasks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPreviewRunsWithoutSaving(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "forsinket", "due_date": userDate(t, -2),
|
||||
})
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ingen dato"})
|
||||
|
||||
resp, body := ts.do(t, "GET", "/api/v1/filters/preview?query=overdue", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("preview: %d %v", resp.StatusCode, body)
|
||||
}
|
||||
tasks, _ := body["tasks"].([]any)
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("overdue matched %d tasks, want 1", len(tasks))
|
||||
}
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/filters", nil)
|
||||
if saved, _ := list["filters"].([]any); len(saved) != 0 {
|
||||
t.Errorf("the preview saved %d filters", len(saved))
|
||||
}
|
||||
}
|
||||
|
||||
// A filter that cannot be compiled must be refused when it is saved, not when it
|
||||
// is next opened and silently returns nothing.
|
||||
func TestInvalidFilterIsRefusedOnSave(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/filters", map[string]any{
|
||||
"name": "Ødelagt", "query": "today & (p1",
|
||||
})
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status %d, want 422", resp.StatusCode)
|
||||
}
|
||||
fields, _ := body["fields"].(map[string]any)
|
||||
if fields["query"] == nil {
|
||||
t.Errorf("the error does not name the query field: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A filter belongs to one person and must not reach anybody else's tasks.
|
||||
func TestFiltersAreScopedToTheirOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "min private opgave", "priority": 1, "due_date": userDate(t, 0),
|
||||
})
|
||||
_, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
|
||||
"name": "Mit filter", "query": "today & p1",
|
||||
})
|
||||
filterID := filter["id"].(string)
|
||||
|
||||
// Somebody else cannot run it.
|
||||
resp, _ := other.do(t, "GET", "/api/v1/filters/"+filterID+"/tasks", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("another user ran the filter: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And writing the same expression themselves finds none of the owner's tasks.
|
||||
_, result := other.do(t, "GET", "/api/v1/filters/preview?query=today+%26+p1", nil)
|
||||
if tasks, _ := result["tasks"].([]any); len(tasks) != 0 {
|
||||
t.Errorf("an identical filter reached another user's tasks: %v", tasks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelsCRUD(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// Labels appear by being used, without being created first.
|
||||
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "med etiket", "labels": []string{"venter"},
|
||||
})
|
||||
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
|
||||
labels, _ := list["labels"].([]any)
|
||||
if len(labels) != 1 {
|
||||
t.Fatalf("got %d labels, want the one created by using it", len(labels))
|
||||
}
|
||||
if labels[0].(map[string]any)["task_count"] != float64(1) {
|
||||
t.Errorf("task_count = %v, want 1", labels[0].(map[string]any)["task_count"])
|
||||
}
|
||||
|
||||
resp, created := ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "haster"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create label: %d %v", resp.StatusCode, created)
|
||||
}
|
||||
// The same name twice is a conflict, not a second label.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "HASTER"})
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Errorf("duplicate label: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
|
||||
resp, _ = ts.do(t, "DELETE", "/api/v1/labels/"+created["id"].(string), nil)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete label: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a label is tidying, not a request to delete the work filed under it.
|
||||
func TestDeletingALabelKeepsItsTasks(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
||||
"content": "vigtig opgave", "labels": []string{"venter"},
|
||||
})
|
||||
taskID := task["id"].(string)
|
||||
|
||||
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
|
||||
labels, _ := list["labels"].([]any)
|
||||
labelID := labels[0].(map[string]any)["id"].(string)
|
||||
|
||||
ts.do(t, "DELETE", "/api/v1/labels/"+labelID, nil)
|
||||
|
||||
resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("the task went with its label: %d", resp.StatusCode)
|
||||
}
|
||||
if got, _ := after["labels"].([]any); len(got) != 0 {
|
||||
t.Errorf("labels = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
Query string
|
||||
Color string
|
||||
SortOrder float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListFilters(ctx context.Context, userID string) ([]Filter, error) {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT id, user_id, name, query, color, sort_order, created_at, updated_at
|
||||
FROM filters WHERE user_id = ? ORDER BY sort_order, created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Filter{}
|
||||
for rows.Next() {
|
||||
var f Filter
|
||||
var created, updated int64
|
||||
if err := rows.Scan(&f.ID, &f.UserID, &f.Name, &f.Query, &f.Color,
|
||||
&f.SortOrder, &created, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.CreatedAt = time.Unix(created, 0).UTC()
|
||||
f.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetFilter is scoped by user: a filter id from somewhere else finds nothing,
|
||||
// rather than running somebody else's saved query.
|
||||
func (db *DB) GetFilter(ctx context.Context, userID, filterID string) (*Filter, error) {
|
||||
var f Filter
|
||||
var created, updated int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT id, user_id, name, query, color, sort_order, created_at, updated_at
|
||||
FROM filters WHERE id = ? AND user_id = ?`, filterID, userID).
|
||||
Scan(&f.ID, &f.UserID, &f.Name, &f.Query, &f.Color, &f.SortOrder, &created, &updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.CreatedAt = time.Unix(created, 0).UTC()
|
||||
f.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateFilter(ctx context.Context, f *Filter) error {
|
||||
if f.ID == "" {
|
||||
f.ID = NewID()
|
||||
}
|
||||
if f.Color == "" {
|
||||
f.Color = "graphite"
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
f.CreatedAt, f.UpdatedAt = now, now
|
||||
|
||||
if f.SortOrder == 0 {
|
||||
var max sql.NullFloat64
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT max(sort_order) FROM filters WHERE user_id = ?`, f.UserID).Scan(&max); err != nil {
|
||||
return err
|
||||
}
|
||||
f.SortOrder = max.Float64 + 1024
|
||||
}
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO filters (id, user_id, name, query, color, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
f.ID, f.UserID, f.Name, f.Query, f.Color, f.SortOrder, now.Unix(), now.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
type FilterUpdate struct {
|
||||
Name *string
|
||||
Query *string
|
||||
Color *string
|
||||
SortOrder *float64
|
||||
}
|
||||
|
||||
func (db *DB) UpdateFilter(ctx context.Context, userID, filterID string, u FilterUpdate) error {
|
||||
set, args := buildUpdate(map[string]any{
|
||||
"name": u.Name,
|
||||
"query": u.Query,
|
||||
"color": u.Color,
|
||||
"sort_order": u.SortOrder,
|
||||
})
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
set = append(set, "updated_at = ?")
|
||||
args = append(args, time.Now().Unix(), filterID, userID)
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE filters SET `+joinComma(set)+` WHERE id = ? AND user_id = ?`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteFilter(ctx context.Context, userID, filterID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM filters WHERE id = ? AND user_id = ?`, filterID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- labels --------------------------------------------------------------------
|
||||
|
||||
type Label struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
Color string
|
||||
SortOrder float64
|
||||
// TaskCount is how many open tasks carry it, which is what the sidebar shows
|
||||
// and what makes an unused label obvious enough to delete.
|
||||
TaskCount int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) ListLabels(ctx context.Context, userID string) ([]Label, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT l.id, l.user_id, l.name, l.color, l.sort_order, l.created_at,
|
||||
(SELECT count(*) FROM task_labels tl
|
||||
JOIN tasks t ON t.id = tl.task_id
|
||||
WHERE tl.label_id = l.id AND t.deleted_at IS NULL AND t.completed_at IS NULL)
|
||||
FROM labels l WHERE l.user_id = ?
|
||||
ORDER BY l.sort_order, l.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Label{}
|
||||
for rows.Next() {
|
||||
var l Label
|
||||
var created int64
|
||||
if err := rows.Scan(&l.ID, &l.UserID, &l.Name, &l.Color, &l.SortOrder,
|
||||
&created, &l.TaskCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.CreatedAt = time.Unix(created, 0).UTC()
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateLabel(ctx context.Context, l *Label) error {
|
||||
if l.ID == "" {
|
||||
l.ID = NewID()
|
||||
}
|
||||
if l.Color == "" {
|
||||
l.Color = "graphite"
|
||||
}
|
||||
l.CreatedAt = time.Now().UTC()
|
||||
|
||||
_, err := db.ExecContext(ctx,
|
||||
`INSERT INTO labels (id, user_id, name, color, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
l.ID, l.UserID, l.Name, l.Color, l.SortOrder, l.CreatedAt.Unix())
|
||||
if isUniqueViolation(err) {
|
||||
return ErrLabelExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var ErrLabelExists = errors.New("store: that label already exists")
|
||||
|
||||
func (db *DB) UpdateLabel(ctx context.Context, userID, labelID, name, color string) error {
|
||||
set, args := buildUpdate(map[string]any{
|
||||
"name": nonEmpty(name),
|
||||
"color": nonEmpty(color),
|
||||
})
|
||||
if len(set) == 0 {
|
||||
return nil
|
||||
}
|
||||
args = append(args, labelID, userID)
|
||||
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE labels SET `+joinComma(set)+` WHERE id = ? AND user_id = ?`, args...)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrLabelExists
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLabel removes the label and its attachments. The tasks stay: deleting a
|
||||
// label is tidying, not a request to delete the work it was filed under.
|
||||
func (db *DB) DeleteLabel(ctx context.Context, userID, labelID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM labels WHERE id = ? AND user_id = ?`, labelID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nonEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
@@ -62,6 +62,12 @@ type TaskFilter struct {
|
||||
CompletedOnly bool
|
||||
|
||||
Search string
|
||||
// FilterSQL is a compiled saved-filter expression: a WHERE fragment over `t`
|
||||
// with its own bound arguments. It is produced by the filterquery package and
|
||||
// never assembled from user text here.
|
||||
FilterSQL string
|
||||
FilterArgs []any
|
||||
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
@@ -133,6 +139,10 @@ func (db *DB) ListTasks(ctx context.Context, userID string, f TaskFilter) ([]Tas
|
||||
where = append(where, `t.rowid IN (SELECT rowid FROM tasks_fts WHERE tasks_fts MATCH ?)`)
|
||||
args = append(args, expr)
|
||||
}
|
||||
if f.FilterSQL != "" {
|
||||
where = append(where, "("+f.FilterSQL+")")
|
||||
args = append(args, f.FilterArgs...)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT t.id, t.project_id, t.section_id, t.parent_id, t.content, t.description,
|
||||
|
||||
Reference in New Issue
Block a user