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

8 lines
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

6 lines
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

OperatorSQL produced
=field = $1
!=field != $1
> < >= <=field > $1 etc.
containsfield LIKE $1 ESCAPE '\\' with %value% (wildcards escaped)
infield 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".

2 lines
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(...) when users isn'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 OR between 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 with limit(1000000) will still execute. Cap limit
    before calling BuildDSLQuery.
  • Building once, sharing across requests. The returned
    *query.QueryBuilder is stateful. Build per request.