Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Querying

Every generated list and read endpoint accepts the same query parameters — page, limit, filter, sort, include, and select. This page documents their grammar and the fields that opt in to each.

page and limit

Standard offset pagination.

?page=2&limit=20
ParameterDefaultMaximum
page1unbounded
limit20200

Values above the maximum are clamped silently. Negative or non-numeric values are rejected with 400 INVALID_QUERY.

The response carries meta.total, meta.page, meta.limit, and meta.pages — see Response Envelope.

cursor (keyset pagination)

Offset pagination skips or duplicates rows when the dataset changes between page fetches — delete a row on page 1 and page 2 silently jumps a record. Keyset (cursor) pagination walks the data by a stable ordering key instead, so the window never shifts. Opt a model in by naming a sortable, effectively monotonic cursor column:

type Event struct {
    maniflex.BaseModel `mfx:"cursor_field:created_at"` // created_at is sortable on BaseModel
    Name string        `json:"name" db:"name"`
}

Equivalently, set ModelConfig.CursorField: "created_at" at registration, or put mfx:"...,cursor_field:<name>" on any of the model’s own fields.

The cursor column must be mfx:"sortable" and not nullable — a pointer field (*time.Time) is rejected at registration. Keyset pagination needs a total order, and NULL has no place in one: the boundary comparison is never true for it, and Postgres and SQLite don’t even agree where NULLs sort. A nullable cursor column would drop or repeat rows across pages, so the model fails to register rather than paginating wrongly.

The presence of ?cursor= switches the request into keyset mode (it supersedes ?page). Send an empty value for the first page, then the meta.next_cursor from each response to fetch the next:

GET /events?cursor=&limit=20          → first page
GET /events?cursor=<next_cursor>&limit=20  → following page

The walk is ordered by (cursor_field, id)id is the implicit tiebreaker so the order is total even when the cursor column ties. The default direction is ascending; sort on the cursor field to reverse it:

GET /events?cursor=&sort=created_at:desc

Any ?sort= on a different field is rejected with 400 in cursor mode, since the keyset order is fixed to the cursor column.

Cursor responses carry a different meta shape — no total/page/pages (the count is skipped, which is the point on large tables):

{ "data": [ ... ], "meta": { "limit": 20, "next_cursor": "eyJ2Ijoi...", "has_more": true } }

has_more is false and next_cursor is omitted on the last page. The token is opaque — treat it as a string and pass it back verbatim.

filter

Each filter is a colon-separated triple — field, operator, value:

?filter=status:eq:published
?filter=views:gt:100
?filter=created_at:gte:2025-01-01

Multiple filters combine with AND:

?filter=status:eq:published&filter=views:gt:100

Filters reference a field by its json name. Only fields tagged mfx:"filterable" may be used; unknown or non-filterable references abort the request with 400 INVALID_QUERY.

Operators

OperatorEffectValue
eqfield = valueone value
neqfield ≠ valueone value
gt, gte, lt, ltenumeric and date comparisonsone value
likeSQL LIKE, case-sensitiveone pattern% and _ are wildcards
ilikeSQL ILIKE, case-insensitiveone pattern% and _ are wildcards
containsfield contains the value, case-insensitiveone literal value
starts_withfield starts with the value, case-insensitiveone literal value
ends_withfield ends with the value, case-insensitiveone literal value
infield IN (…)at least one comma-separated value
not_infield NOT IN (…)at least one comma-separated value
betweenfield ≥ lo AND ≤ hi (inclusive)exactly two comma-separated values lo,hi
is_nullfield IS NULLno value
not_nullfield IS NOT NULLno value
?filter=tag:in:go,rust,zig
?filter=amount:between:100,500
?filter=created_at:between:2025-01-01,2025-03-31
?filter=archived_at:is_null
?filter=title:ilike:%intro%
?filter=title:contains:intro

Patterns vs. literals

like and ilike take a pattern: % matches any run of characters and _ matches exactly one. That is what makes ?filter=title:ilike:%intro% work — but it also means a value the user typed is interpreted rather than matched. ?filter=label:like:50% finds 500 units and 50 off as readily as the 50% you were looking for, and there is no portable way to escape a % in a pattern (SQLite has no escape character by default; Postgres has a backslash).

contains, starts_with, and ends_with take a literal: % and _ in the value are escaped for you and match themselves, so ?filter=label:contains:50% finds exactly the labels containing 50%. They are case-insensitive on both backends. Use them for anything a user typed — a search box, a filename, an SKU — and reach for like/ilike only when the caller genuinely is writing a pattern.

Note that % must still be percent-encoded in a URL (%25), as in any query string:

?filter=label:contains:50%25       → matches the literal "50%"
?filter=label:like:50%25           → matches "50%", "500 units", "50 off", …

When a relation is declared on the model, you can filter by a field on the related table using dot notation:

?filter=user.role:eq:admin
?filter=posts.status:eq:published

The related field must itself be filterable. The framework joins the related table for the query; no separate ?include= is required to filter on it (but you still need ?include= to return the related row).

?q= runs a native full-text search over every field tagged mfx:"searchable" and orders the results by match relevance:

?q=hello world
?q=postgres&filter=tag:eq:db

This is distinct from filter: full-text search uses the database’s own ranking, stemming, and tokenisation rather than literal comparison, so ?q=run also matches running, and the densest match ranks first. The backend’s native machinery does the work — a tsvector column and GIN index on PostgreSQL, an FTS5 index on SQLite — both provisioned automatically during migration.

  • Only models with at least one mfx:"searchable" field accept ?q=; on any other model it aborts with 400 INVALID_QUERY. Searchable fields must be text columns.
  • ?q= combines with ?filter= (ANDed) and the usual ?page=/?limit= offset pagination. It cannot be combined with ?cursor=, since keyset order and relevance order are mutually exclusive.
  • An empty value (?q=) is ignored — the list is returned unfiltered.
  • On PostgreSQL the text-search configuration defaults to english; override it per model with ModelConfig.SearchLanguage.
type Article struct {
    maniflex.BaseModel
    Title string `json:"title" db:"title" mfx:"required,searchable"`
    Body  string `json:"body"  db:"body"  mfx:"searchable"`
}
// GET /articles?q=keyset+pagination → relevance-ranked matches

sort

Each sort is field:direction:

?sort=created_at:desc
?sort=title:asc

Multiple sorts compose left-to-right (primary, secondary, …):

?sort=status:asc&sort=created_at:desc

Only fields tagged mfx:"sortable" may be used. BaseModel’s created_at and updated_at are sortable by default.

Sorting on a relation field

Use relation.field to sort by a column on a BelongsTo parent. The server adds the LEFT JOIN automatically — no filter or include on that relation is required:

?sort=user.name:asc
?sort=vendor.name:desc&filter=status:eq:open

The related field must be tagged mfx:"sortable" on the parent model. Only BelongsTo relations are supported; relation.field on a HasMany or ManyToMany returns 400, as does an unknown relation or a non-sortable related field.

include

Loads related records inline. The value is a comma-separated list of relation keys:

?include=user
?include=user,comments

Each key becomes a nested object (for BelongsTo) or array (for HasMany and ManyToMany) on the returned row. See Relations for how relation keys are derived.

Includes are populated by separate queries after the main query — they do not multiply rows or affect pagination.

One level of nesting

A key may carry a single dot to load a relation of the related model:

?include=author.company
?include=author.company,comments

?include=author.company implies author — the parent is what the child hangs off — so you do not need to name both.

Two segments is the limit. ?include=a.b.c is refused with 400 INVALID_QUERY. Each level is one more batched query, and the tree comes from the client, so leaving it uncapped would let a caller choose how much work a request costs.

Every segment must name a real relation; a typo is a 400, not a silently missing key. Nested rows are scoped, decrypted and field-filtered exactly as the first level is — a forced filter (db.Tenancy, db.ForceFilter) applies at every level, and hidden / writeonly fields on the nested model stay out.

Go callers: nesting applies to the JSON response. The typed relation structs (post.Author) are still populated one level deep, so post.Author.Company is not filled in by a typed read. Use the JSON path, or a second maniflex.Read.

select

Request a subset of fields instead of the full row. Useful for wide tables (payroll, product catalogues with 40+ attributes) where most columns are irrelevant to the caller.

?select=id,name,department
?select=id,amount,status

The value is a comma-separated list of JSON field names. Unknown names abort the request with 400 INVALID_QUERY. Fields tagged mfx:"hidden" or mfx:"writeonly" are still stripped from the response even if explicitly selected — the projection happens at the database layer, not as an ACL bypass.

?select= applies to both list (GET /:model) and read (GET /:model/:id) endpoints. It can be combined freely with filter, sort, and include.

Putting it together

A complete request that exercises all parameters:

GET /api/posts
    ?filter=status:eq:published
    &filter=views:gte:100
    &sort=created_at:desc
    &include=user,comments
    &select=id,title,views,status
    &page=1
    &limit=20

The framework parses the query string once in the Deserialize step into ctx.Query (a *QueryParams), which middleware can read and modify before the DB step. Tenant-scoping middleware, for example, appends a filter to ctx.Query.Filters to enforce row-level access — see Example 2.