Query DSL
GoFastr ships a small agent-friendly query DSL. Strings can be
generated by an LLM, parsed safely, validated against the entity
registry, and compiled to a core/query.QueryBuilder — never to raw
SQL strings the agent supplied.
Row scopes are NOT applied.
dsl.BuildDSLQueryrefuses hidden and
NoQueryfields and projects only visible columns, but it does not
add owner, tenant, or soft-delete predicates. Running its query
directly against an owner- or tenant-scoped entity returns every
owner's rows and trashed rows alike. Do not wire agent-generated DSL
straight toBuildDSLQueryon a scoped entity — go through the CRUD
handler (which applies the scopes) or add the scope predicates
yourself.
Quickstart
qb, err := dsl.BuildDSLQuery(app.Registry, `posts.where(status="published", views>=10) .include(author) .order(created_at DESC) .limit(10)`,)if err != nil { … }sqlStr, args := qb.Build() // compile to SQL + bound argsrows, err := db.QueryContext(ctx, sqlStr, args...)
BuildDSLQuery returns a builder you can extend with additional
calls. Use dsl.ParseDSL if you only need the parsed AST.
Grammar
query := entity ("." call)*call := where(args) | include(args) | order(args) | limit(N) | after(cursor)where := field op value (, field op value)*op := = | != | > | < | >= | <= | contains | ininclude := relation (, relation)*order := field [ASC|DESC]
- Identifiers (entity, field, relation) are unquoted.
- String values use
"…"or'…'. Numeric and boolean values are
bare (123,1.5,true). - Whitespace inside calls is permitted. The parser is whitespace-
tolerant but the splitter respects quotes and nested parens. - The parser is strict about types: every entity, field, and relation
is validated against the registry before SQL is produced.
Operators
| Operator | SQL produced |
|---|---|
= | field = $1 |
!= | field != $1 |
> < >= <= | field > $1 etc. |
contains | field LIKE $1 ESCAPE '\\' with %value% (wildcards escaped) |
in | field IN ($1, $2, …) over [a, b, c] syntax |
contains escapes %, _, and \ in the user input so a query like
name contains "50%_off" matches that literal string instead of
acting as a wildcard pattern.
in accepts either field in ["a","b"] or field in [1,2,3]. Values
are type-coerced based on the field's declared schema.FieldType.
Clauses
where(...)
Comma-separated predicates. Multiple predicates AND together. There is
no DSL-level or — express it as separate calls if your registry
supports it, or use the lower-level core/query builder.
include(relation, ...)
Validates each relation exists on the entity. Validation only —
includes do not currently translate into SQL joins. Resolve eager
loading through the CRUD list endpoint's ?include= query parameter, or
the crud.EagerLoad helper — not through the DSL.
order(field [ASC|DESC])
One ordering per .order() call. Chain multiple .order() calls for
multi-column ordering. Direction defaults to ASC when omitted.
limit(N)
N must be a positive integer. There is no offset() — use cursor
pagination via after(cursor) instead.
after(cursor)
Forward keyset pagination. BuildDSLQuery wires after(value) into a
field > value predicate on the entity's cursor column —
Pagination.CursorField when set, otherwise the primary key. Unlike the HTTP cursor API (which
round-trips an opaque base64 token), the DSL takes the bare keyset
value: posts.after(1024) means "rows after id 1024".
posts.where(status="published").order(id).after(1024).limit(20)→ … WHERE (status = $1) AND (id > $2) ORDER BY id LIMIT $3
Composite cursors (EntityConfig.Pagination.CursorFields) have no single-value
DSL form, so after() on such an entity returns an error rather than
silently paging from the start — use the HTTP cursor API for those.
Errors the parser rejects
- Unknown entity:
users.where(...)whenusersisn't registered. - Unknown field, relation, or order direction.
- Unsupported operator.
limit(0)or negative limits.- Unclosed parens or unterminated strings.
All errors are returned as plain Go errors with dsl: prefix.
When to use it (and when not to)
Use the DSL when:
- An agent is generating queries from a natural-language prompt.
- You want validation that the query references real fields before
hitting the database. - The query shape is simple (filter + order + limit).
Drop down to core/query directly when:
- You need joins, subqueries, raw SQL fragments, or window functions.
- You need
ORbetween predicates. - You need projections beyond
SELECT * FROM entity_table.
Common mistakes
- Trusting
include()to eager-load. It only validates. Combine
it with the include/eager-loading API to actually fetch the relation. - Assuming you must cap
limityourself. The parser rejects any
limit()abovemaxDSLLimit(10,000), solimit(1000000)fails to
parse rather than executing. Cap it lower than that if your app wants
a smaller ceiling, but the unbounded case is already closed. - Building once, sharing across requests. The returned
*query.QueryBuilderis stateful. Build per request.