Files
verdande/internal/filterquery/filterquery.go
T

416 lines
12 KiB
Go
Raw Normal View History

// 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")
}
// Refused before it is parsed, not while.
//
// `combine` rebuilds the whole accumulated SQL string and re-copies the
// argument slice per operator, so the cost is quadratic in the number of
// terms: fifty thousand of them — three hundred kilobytes, which fits in a
// query string — took seven seconds of CPU and forty-six gigabytes of
// allocation in one request. The preview endpoint runs this on whatever is
// typed, and there is no write timeout on the server to cut it off.
//
// Two hundred terms is a filter nobody has ever written and far below where it
// costs anything.
if len(p.tokens) > maxTokens {
return Compiled{}, fmt.Errorf("filteret er for langt")
}
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 -------------------------------------------------------------------
// The ceilings on one filter. See Parse for why.
const (
maxTokens = 200
maxDepth = 32
)
type parser struct {
tokens []token
pos int
ctx Context
// depth guards against a filter that is only parentheses. Each level is a
// stack frame, and nothing else stops somebody sending a megabyte of them.
depth int
}
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")
}
// Nesting is stack depth. A filter made of half a million opening parentheses
// is a megabyte of text and a stack frame for each.
p.depth++
defer func() { p.depth-- }()
if p.depth > maxDepth {
return Compiled{}, fmt.Errorf("filteret har for mange niveauer")
}
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
}