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.BuildDSLQuery refuses hidden and
NoQuery fields 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 to BuildDSLQuery on a scoped entity — go through the CRUD
handler (which applies the scopes) or add the scope predicates
yourself.

Quickstart

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

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 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".

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.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(...) 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.
  • Assuming you must cap limit yourself. The parser rejects any
    limit() above maxDSLLimit (10,000), so limit(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.QueryBuilder is stateful. Build per request.