Skip to content

The index query pipeline

Reference for the pipeline stages, filter DSL, and search drivers.

Stage order

resolve → authorize(viewList) → base → softDeleteScope → search
  → filters → sort → eagerLoad → paginate → serialize

resolve/authorize run before a query builder exists and paginate/serialize are terminal, so AdoniaPipeline owns the six query-mutating stages in the middle. base is the caller’s: the controller starts from Model.query() and applies the panel/tenant/lens scopes before run.

buildIndexPipeline({ model, table, search, resource }) returns a pipeline with all five built-in stages wired. Plugins reshape it with before/after/replace (§16.2).

const table = resolveTable(resource, defaults)
const allowlists = allowlistsFor(table)
const state = parseIndexQueryState(ctx.request.qs(), { allowlists, defaults, search })

const query = Model.query()
const hints = createEagerHints()
await buildIndexPipeline({ model: Model, table, search, resource }).run(query, {
  ctx, resource, state, allowlists, hints, dialect: dialectOf(query),
})

const page = await paginateIndex(query, {
  pagination: table.pagination,
  model: Model, columns: table.columnsByKey, sortable: allowlists.sortable,
  defaultSort: table.defaultSort, state, hints,
  onInvalidCursor: PostResource.onInvalidCursor,
})
const columns = selectVisibleColumns(table, state.columns)
const records = serializeIndexPage(page, columns, ctx, can)

run resolves to void, not to the builder — a Lucid builder is thenable, so a promise can never resolve to one; it would adopt it and execute the half-built query. For the same reason every stage, filter and driver mutation is awaited through pendingMutation(...), which awaits real promises only. Write (query) => { query.where(…) }, not (query) => query.where(…), and it does not matter either way.

softDeleteScope

Detects soft deletes with detectSoftDeletes(model, resource): an explicit static softDeletes on the resource wins, otherwise a deletedAt / deleted_at column on the model enables the scope.

?trashed= predicate
absent where <col> is null
with (none)
only where <col> is not null

A declared Filter.ternary('trashed').trashed() takes precedence over ?trashed=, and applies no predicate of its own — the scope is this stage’s job, and applying it twice would contradict itself.

Resolves the searchable allowlist into table-qualified SQL columns, then hands them to the configured driver. Dotted paths compile to whereHas.

filters

Validates each filters[<key>] value against that filter’s compiled Vine mini-schema and drops it silently on failure (§8.3), then calls apply. This is the only place validation happens: parseIndexQueryState is synchronous and can only guarantee the JSON shape.

sort

Maps a sort key through its column’s sortColumn, then:

  • a dotted target LEFT JOINs each hop (belongsTo/hasOne only — anything with higher cardinality would multiply rows) under a deterministic alias, author.companyadonia_author__company, recorded in EagerHints.joins so nothing joins it twice;
  • an aggregate column orders by its sub-select alias, unqualified;
  • the primary key is always appended, so the order is total and pages never overlap.

eagerLoad

Replays the unioned EagerHintspreload (dotted paths folded into one nested preload), withCount, withAggregate — for the columns the request will actually render. Hiding a column with ?columns= removes its query.

paginate (terminal)

paginateIndex dispatches on the resource’s effective mode — config → panel → static pagination, folded once by resolvePaginationDefaults alongside static perPage (§6.1, most-specific wins; a declared perPage is also unioned into perPageOptions so the client’s echo is not dropped).

Offset is LIMIT/OFFSET + COUNT(*) and emits the frozen §6 meta. offsetMeta owns the boundaries: lastPage is 1 on an empty set (there is always a page one), from/to are 0/0 when the page is empty and report the real row count on a partial last page, and a page past the end is an empty page, never an error — currentPage echoes the request rather than clamping, so the client can render “no results” and offer the way back.

Cursor (the cursor contract) is keyset. It applies at most one sort key plus the primary-key tiebreaker — a multi-sort query string keeps its first key and silently drops the rest, staying in cursor mode — and emits

ORDER BY (sort_col IS NULL) ASC, sort_col <DIR>, pk <DIR>

which pins NULLS LAST identically on PostgreSQL, MySQL and SQLite. The seek is the expanded three-branch OR with an explicit NULL branch, never a row-value comparison ((a,b) > (?,?) is UNKNOWN against NULL — silently wrong). Each page fetches perPage + 1 rows; the extra row is hasMore, which is how the mode avoids a COUNT(*). Because it owns the whole ORDER BY — and inverts it wholesale to walk a prevCursor — the sort stage stands down in cursor mode.

Meta is { perPage, nextCursor, prevCursor, hasMore }; no total, no lastPage. Tokens are opaque base64url JSON ({v, s, pk, b?}), unsigned: they carry no filter or scope state, and every field is re-validated against the sortable allowlist and the request’s resolved sort before use, so a forged token can only describe a query the same user could already ask for. A token that fails any of those checks is a 422 E_ADONIA_INVALID_CURSOR; resources that would rather degrade a stale deep link set static onInvalidCursor = 'reset' and get page one instead.

Filters (§8.2)

Filter is both the DSL namespace and the abstract base class plugins extend; they are the same object.

Factory Wire type Value
Filter.select(key) select-filter one declared option
Filter.multiSelect(key) multi-select-filter array of declared options
Filter.ternary(key) ternary-filter yes | no | any
Filter.ternary(key).trashed() ternary-filter with | only (else default)
Filter.dateRange(key) date-range-filter { from?, to? } ISO-8601
Filter.numberRange(key) number-range-filter { min?, max? }
Filter.relation(key, relation?) relation-filter array of related keys
Filter.custom(key, schema, apply) custom-filter whatever schema accepts

.column(name) retargets a filter at a different column; a dotted name compiles to whereHas. A date-only to bound is extended to the end of that day, so to=2024-03-01 includes the whole first of March.

Search drivers (§8.4)

The built-in database driver builds grouped LIKE/ILIKE '%q%' across the searchable allowlist: AND across whitespace-separated terms, OR across columns, all inside ONE grouped where so a search can never widen past an active filter. Postgres gets ILIKE; every other dialect gets LOWER(col) LIKE LOWER(?). %, _ and ! in the term are escaped with ! (not \, which MySQL reads as a string escape).

Register an alternative:

registerSearchDriver(
  defineSearchDriver({
    name: 'meilisearch',
    async search(resource, term) {
      const hits = await index(resource).search(term)
      return hits.map((hit) => hit.id) // merged as `whereIn(<pk>, ids)`
    },
  })
)

A driver returns matching ids, a (query) => void mutation, or null for “nothing to narrow on” (distinct from [], which is an empty result and yields an empty page). config.search.driver selects it; an unregistered name is a boot error, not a silent drop — a typo must not disable search across every resource.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close