Skip to content

The query language for developers

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

SLQ is Radd's text query language. Two dialects share one grammar: items (GET /items, saved views) and worklog (the timesheet).

Grammar

The grammar is small and frozen:

query     := [expr] [ORDER BY order (',' order)*]
expr      := and_expr (OR and_expr)*          # AND binds tighter than OR
and_expr  := term (AND term)*
term      := [NOT] (comparison | '(' expr ')')
comparison:= field op value
           | field [NOT] IN '(' value (',' value)* ')'
           | field IS [NOT] EMPTY
order     := field [ASC|DESC]

— server/src/radd/modules/items/slq/parser.py

Keywords (AND, OR, NOT, IN, IS, EMPTY, ORDER, BY, ASC, DESC) are case-insensitive. An operator is one of = != ~ > < >= <=, where ~ is a case-insensitive substring match. Group a term with parentheses to override precedence. An empty query is valid — a saved view with no filter compiles to SELECT *.

Quote a value that has a space, punctuation, or that matches a keyword or sentinel by spelling: title ~ "board scroll". An unquoted value is a bareword — a field name, a keyword, an enum member, a number, or a date. Quoted values never carry the special meaning of me or none; only a bare, unquoted value does.

A date is YYYY-MM-DD, or a relative form resolved at compile time against the server's date. The relative forms are today, today+3d, and today-2w; the units are d for day and w for week.

TODAY_LITERAL = "today"
RELATIVE_DATE_RE = re.compile(r"^today(?:(?P<sign>[+-])(?P<count>\d+)(?P<unit>[dw]))?$")
RELATIVE_UNIT_DAYS = {"d": 1, "w": 7}

— server/src/radd/modules/items/slq/helpers.py

me and none are sentinels, not text. assignee = me matches the signed-in user. assignee = none matches an unset assignee. The compiler rejects a bare me or none everywhere it has no meaning. Passing it to a field that does not define it raises a compile error, not a silent no-op:

def plain(value: Value, field: str) -> str:
    """A literal value: bare `me`/`none` are rejected where they have no meaning."""
    for sentinel in (ME_LITERAL, NONE_LITERAL):
        if is_sentinel(value, sentinel):
            raise SlqError(
                f"'{sentinel}' is not a valid value for field '{field}'", value.position
            )
    return value.text

— server/src/radd/modules/items/slq/helpers.py

An invalid query raises a positioned error: 422 {"detail": "...", "position": <character offset>}. The offset points at the offending token, so an editor can put the caret there.

A dialect is rooted at the entity it returns

This is a design rule, not a style choice. GET /items returns work items, so the item dialect's fields all describe an item — assignee, state, priority. The timesheet returns worklogs, so the worklog dialect's fields all describe a worklog — author, category, worked_on.

The rule matters because a root cannot express a question about a row it does not return. A general worklog — logged against a work category with no linked issue — has no item. An item-rooted query can filter items that have worklogs. It can never produce a row for a worklog with no item, because its rows are items. Only a worklog-rooted query can say issue IS EMPTY and mean it:

issue IS EMPTY        -- general worklogs
issue = DEV-123       -- a specific issue by key
issue.<anything>      -- handed to the ITEM compiler

— docs/specs/98-worklog-slq.md

No number of worklog_* fields added to the item dialect closes this gap — it is a correctness limit, not an ergonomics one. That is why the worklog dialect exists as a second dialect instead of a wider item dialect.

The two dialects

Dialect Rooted at Returns Used by
items a work item work items GET /items, saved views, boards, lists
worklog a worklog worklogs the timesheet (GET /timesheet)

The item dialect's field catalog is the larger of the two. It holds the builtins (project, state, category, assignee, label, cycle, release, …), the ancestor fields (epic.*, parent.*), and every custom field by its registry key. The worklog dialect is deliberately small — seven fields, the whole table: issue, project, author, category, worked_on, time, note. It stays small because of delegation.

Delegation

issue.<field> on the worklog dialect hands the condition to the item compiler rather than restating the item field surface:

async def _delegated_issue_condition(
    session: AsyncSession, ctx: _Ctx, node: Condition
) -> ColumnElement[bool]:
    """`issue.<field> <op> <value>` — compiled by the ITEM dialect, correlated
    through `worklog.item_id`. The item compiler owns validation too, so an
    unknown `issue.` field reports the item dialect's own error (with the
    position rebased onto this query so the caret still lands correctly)."""
    inner = _rebase(node, node.field[len(ISSUE_PREFIX) :])
    try:
        compiled = await compile_item_query(
            session,
            Query(where=inner, order=()),
            definitions_by_key={},
            current_user_id=ctx.current_user_id,
            denied_fields=ctx.denied_item_fields,
        )
    except SlqError as error:
        raise SlqError(str(error), node.field_position) from None
    if compiled.where is None:
        raise SlqError(f"unknown field '{node.field}'", node.field_position)
    matching = select(WorkItem.id).where(compiled.where)
    return Worklog.item_id.in_(matching)

— server/src/radd/modules/timelogging/slq/compiler.py

A second copy of the item field catalog inside the worklog dialect would fork the moment either one changed. A new custom field, a new ancestor field, or a new plugin field would need an update in two places, not one. The two copies would drift apart the first time somebody forgot the second update. Delegation instead means the worklog dialect inherits builtins, custom fields, ancestor fields, and plugin fields with nothing to keep in sync.

Delegation is exact for a to-one relation. A worklog has exactly one issue. Two delegated conditions, issue.a and issue.b, ANDed together, select the same rows a sub-query would. Both conditions describe the one issue linked to that worklog, so there is no difference to approximate. A to-many relation would need a real sub-query grammar for same-row conjunction. SLQ does not have one yet, because nothing needs it yet.

The two seams, and the one-way dependency

Both directions of cross-entity reach exist, and they use different mechanisms on purpose:

  • item → other module (logged_by, commented_by): the plugin SlqFieldSpec registry (kernel registries.slq_fields). The items module never imports timelogging or comments — it dispatches to whatever registered the field name.
  • other module → item (issue.*): a direct call to items.slq.compile_query, a public service function, from a module that already declares depends_on=(..., "items").

The dependency stays one-way: items never depends on timelogging or comments, but timelogging and comments both depend on items. The module-boundary rule in CLAUDE.md forbids reversing either seam. items may not hardcode a worklog-shaped field, and it may not reach into another module's tables for a plugin condition.

Adding an SLQ field to the item dialect from a plugin

SlqFieldSpec is the registration point. It carries a name, a label, and a resolver that returns a Select of matching work-item ids:

@dataclass(frozen=True)
class SlqFieldSpec:
    """A custom SLQ query field a plugin contributes — the SLQ engine's inversion of its hardcoded
    field set. Two uses, same mechanism: a plugin's OWN data (`note ~ "foo"`), and RELATIONAL
    predicates over a module's child rows (`logged_by = me`, `commented_by = "alice@corp.com"` —
    find issues by who logged time on them or commented on them).

    `item_ids` returns a SQLAlchemy `Select` of the work-item ids that MATCH (positive sense); the
    items query engine wraps it as `work_item.id IN (…)` and applies negation."""

    name: str  # the SLQ field keyword, e.g. "note"
    label: str  # human label (autocomplete / errors)
    item_ids: Callable[[bool, str, SlqFieldContext], Any]

— server/src/radd/kernel/specs.py

The real example is timelogging's logged_by — "which issues has Alice logged time on" is a question about worklogs whose answer is a set of items:

def logged_by_item_ids(contains: bool, value: str, ctx: SlqFieldContext) -> Select:
    """Work-item ids with at least one worklog whose author matches `value`.

    `me` resolves to the acting user. Otherwise the value matches a person by
    email or name — exactly when `contains` is False, substring when it is
    true, both case-insensitively, so `logged_by ~ ali` behaves like the
    builtin people fields rather than inventing a second convention."""
    stmt = select(Worklog.item_id).where(Worklog.item_id.is_not(None))
    if ctx.is_me:
        return stmt.where(Worklog.author_id == ctx.current_user_id)

    stmt = stmt.join(User, User.id == Worklog.author_id)
    if contains:
        needle = f"%{value}%"
        return stmt.where(or_(User.email.ilike(needle), User.name.ilike(needle)))
    return stmt.where(or_(User.email.ilike(value), User.name.ilike(value)))

— server/src/radd/modules/timelogging/slq/item_fields.py

Registered on the plugin manifest:

slq_fields=(
    SlqFieldSpec(name="logged_by", label="Logged by", item_ids=logged_by_item_ids),
),

— server/src/radd/modules/timelogging/init.py

Two details worth calling out:

  • SlqFieldContext carries is_me, not the raw text. me is a grammar sentinel, so the parser marks it as unquoted. The engine then sets ctx.is_me=True with value="", rather than handing your resolver the string "me". Branch on the flag. This is what stops logged_by = me from colliding with a person literally named "me".
  • The resolver touches only its own table (plus the auth.User spine, which every module may join). logged_by_item_ids never imports anything from items — it queries Worklog and wraps the result as a Select of item ids. items never learns that worklogs exist.
  • Itemless worklogs never match. logged_by_item_ids filters Worklog.item_id.is_not(None) explicitly. A general worklog has nothing to name. The resolver excludes it on purpose — no join drops it by accident.

A plugin field supports only =, !=, and ~. IN and IS EMPTY are not part of the SlqFieldSpec contract.

Autocomplete

GET /items/slq/suggest (and GET /timesheet/slq/suggest for the worklog dialect) takes the query text and a cursor position. It returns ranked completions — field names, operators, keywords, or values — depending on what the cursor sits on. Matches rank prefix before substring, then alphabetical. The engine caps the list of value candidates. It does not cap field, operator, or keyword candidates, because every field must stay reachable by typing its prefix.

The worklog suggester delegates issue.* completion the same way the compiler delegates compilation. It splices the prefix out, asks the item suggester, and shifts the returned offset back by the prefix width:

async def _delegate(
    session: AsyncSession, *, actor: User, q: str, cursor: int, detection
) -> SuggestResponse:
    """Splice `issue.` out, ask the item suggester, shift the offset back.

    Removing a fixed-width prefix at a known offset means the mapping is just
    `+len(prefix)` for anything at or after it — no re-parsing, and the
    caller's `replace_from` still points at the character the user is
    actually editing (the part AFTER `issue.`), so the completion drops in
    without re-typing it."""
    cut = _prefix_start(q, cursor, detection)
    trimmed = q[:cut] + q[cut + len(ISSUE_PREFIX) :]
    inner_cursor = cursor - len(ISSUE_PREFIX) if cursor > cut else cursor
    inner = await suggestions_for(
        session,
        q=trimmed,
        cursor=max(inner_cursor, 0),
        scope=await _actor_scope(session, actor),
        definitions_by_key={},
    )
    shift = len(ISSUE_PREFIX) if inner.replace_from >= cut else 0
    return SuggestResponse(
        context=inner.context,
        replace_from=inner.replace_from + shift,
        field=f"{ISSUE_PREFIX}{inner.field}" if inner.field else None,
        suggestions=inner.suggestions,
    )

— server/src/radd/modules/timelogging/slq/suggest.py

So issue.assi completes to issue.assignee, and issue.assignee = offers me, none, and real people — the item dialect's own value resolvers, reused rather than reimplemented.

TODO(verify): a plugin field registered through SlqFieldSpec (logged_by, commented_by) does not appear in the item dialect's live field-name autocomplete at GET /items/slq/suggest. The field-candidate builder (server/src/radd/modules/items/slq/suggest.py, _field_candidates) enumerates only SlqField builtins and custom-field definitions. The plugin registry feeds only the did-you-mean hint on an unrecognized field name (server/src/radd/modules/items/slq/helpers.py, Context.field_names). The field is fully queryable by typing it out — it is not offered while typing. Confirm this is the intended scope before this becomes a stated limit on the published page.

Reusing the query machinery

items.slq exports the lexer, parser, and coercion/negation helpers (tokenize, parse, render, plain, is_sentinel, compare, date_value, escape_like, and the rest) on purpose:

# The lexer, parser and the coercion/negation helpers are GENERIC query
# machinery — nothing in them knows about work items. They are exported so a
# second dialect (timelogging's worklog SLQ, spec 98) can be a compiler and a
# field catalog rather than a second copy of the language. Longer term they
# belong in the kernel; keeping them here avoids a large move for one consumer.

— server/src/radd/modules/items/slq/init.py

If you build a third dialect, import these from radd.modules.items.slq. Do not copy the lexer or parser into your own module. A third copy carries the same fork risk delegation avoids elsewhere, applied instead to the front half of the language rather than the field catalog. The code comment states a plan to move this machinery into the kernel eventually. That move has not happened yet, so the import path today is still through items.


Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.

Clone this wiki locally