# The query language

2026-08-11

Phase 5. The three phases after this — dashboards, bulk change, REST — are all
the same query with a different thing on the end, which is why it comes now
rather than after them.

## What it is

`status = Open AND assignee = me ORDER BY priority DESC`

Fields, operators, values, `AND`/`OR`, parentheses, and an ordering. Enough to
express the questions people actually ask a tracker, and deliberately not a
programming language.

## 🚨 The thing that decides the whole implementation: this is user input

A query language is a string typed by a member that turns into SQL. There is
exactly one way to build that safely and a hundred ways to build it unsafely,
and the unsafe ones all work perfectly until somebody types a quote.

So:

- **The parser never produces SQL.** It produces a tree. A separate compiler
  walks the tree and emits a fragment with **bound parameters only** — no value
  from the query text is ever concatenated into SQL, not once, not even numbers.
- **Field names are looked up in a whitelist**, and the whitelist maps a name a
  person types to a column this code owns. A field nobody registered is an
  error, not a passthrough.
- **Operators are a fixed set**, matched exactly. `LIKE` is reachable only
  through the `~` operator and its pattern is escaped.

🚨 **A parse failure is an error message, never a partial query.** A query the
parser half-understood is the dangerous case: it runs, it returns rows, and the
rows are wrong in a way that looks like an answer. `status = Open AND` must
refuse rather than quietly become `status = Open`.

## Errors say where

A query language people cannot debug is a query language they stop using. Every
error carries the position it failed at and what was expected there —
*"expected a value after `=` (position 9)"* rather than *"invalid query"*.

## `me` and `now`

`assignee = me` is the single most useful query anybody writes, and it has to
mean the person reading it, not the person who saved the filter. So `me`
resolves **at execution against the current viewer**, and a saved filter shared
with a team shows each of them their own work.

🚨 **Which means a saved filter cannot be cached by its text alone.** Two people
running the same filter get different rows, correctly. Anything that caches
results later has to key on the viewer as well, and this is the note that says
so before somebody adds caching.

Likewise `now`, `today`, `-7d` are resolved when the query runs.

## Empty is not nothing

`assignee = EMPTY` is a real question — unassigned work is what a triage queue
*is*. It compiles to `IS NULL`, and it needs its own keyword because no value
can express it: an empty string is a value, and `assignee =` is a parse error.

## What it does not do

- **No `JOIN`s a person can write.** Fields that need one — sprint, label,
  component — are handled by the compiler for that field, which knows how. A
  query language that lets somebody express a join is one that lets them express
  a slow one.
- **No projection.** `SELECT` is not part of the grammar; a query says which
  issues, and the screen decides what to show about them.
- **No sub-queries.** The shapes that would need one (`issue in an epic whose
  status is X`) are worth adding as *functions* later, each with a compiler that
  knows how to be fast.

## Saved filters

A name, a query, an owner, and whether it is shared. 🚨 **Sharing is a flag, not
a permission model** — a shared filter is visible to anyone who can see the
project, because a filter reveals nothing the project does not. The rows it
returns are still filtered by what the viewer may see.

## Not matched, deliberately

- **JQL's function library.** Jira has dozens — `membersOf`, `linkedIssues`,
  `votedIssues` and so on. Convoro starts with the handful that answer real
  questions and adds them when somebody asks, because each one is a query
  somebody has to be able to run quickly.
- **Autocomplete as you type.** Worth having, not worth blocking on. The error
  messages carry positions specifically so it can be built later without
  changing the parser.
