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.
Quickstart
qb, err := framework.BuildDSLQuery(app.Registry, `posts.where(status="published", views>=10) .include(author) .order(created_at DESC) .limit(10)`,)if err != nil { … }rows, err := qb.Run(ctx, db)
BuildDSLQuery returns a builder you can extend with additional
calls. Use framework.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 via framework.WithIncludes after building the query.
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 — 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.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. - Passing a raw user string to the DSL. Safe at the SQL injection
level (the parser produces parameterised queries), but unbounded —
a query withlimit(1000000)will still execute. Caplimit
before callingBuildDSLQuery. - Building once, sharing across requests. The returned
*query.QueryBuilderis stateful. Build per request.