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 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-17 06:57:43 +02:00
co-authored by Claude Opus 5
parent a96aae57ec
commit 4d142b2fa7
7 changed files with 1460 additions and 0 deletions
+385
View File
@@ -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
}
+373
View File
@@ -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: anders@example.dk",
"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','kristian@example.dk','Kristian'), ('u2','anders@example.dk','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
}