-
-
Notifications
You must be signed in to change notification settings - Fork 0
The query language for developers
SLQ is Radd's text query language. Two dialects share one grammar: items
(GET /items, saved views) and worklog (the timesheet).
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.
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.
| 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.
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.
Both directions of cross-entity reach exist, and they use different mechanisms on purpose:
-
item → other module (
logged_by,commented_by): the pluginSlqFieldSpecregistry (kernelregistries.slq_fields). Theitemsmodule never importstimeloggingorcomments— it dispatches to whatever registered the field name. -
other module → item (
issue.*): a direct call toitems.slq.compile_query, a public service function, from a module that already declaresdepends_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.
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:
-
SlqFieldContextcarriesis_me, not the raw text.meis a grammar sentinel, so the parser marks it as unquoted. The engine then setsctx.is_me=Truewithvalue="", rather than handing your resolver the string"me". Branch on the flag. This is what stopslogged_by = mefrom colliding with a person literally named "me". -
The resolver touches only its own table (plus the
auth.Userspine, which every module may join).logged_by_item_idsnever imports anything fromitems— it queriesWorklogand wraps the result as aSelectof item ids.itemsnever learns that worklogs exist. -
Itemless worklogs never match.
logged_by_item_idsfiltersWorklog.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.
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 atGET /items/slq/suggest. The field-candidate builder (server/src/radd/modules/items/slq/suggest.py,_field_candidates) enumerates onlySlqFieldbuiltins 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.
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.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items