Leave table() off and the index columns are derived from the schema: the
first five tabulatable fields, in document order, no filters and no search box.
That is a good default and a bad blog admin — what an editor scans for is the
author, the tags, how many comments arrived and when it went out, none of which
is the first five form fields.
Declaring table() replaces derivation wholesale. There is no merge, on
purpose: a partial override would silently change the visible column set every
time somebody added an unrelated field.
Columns
t.columns([
C.text('title').label('Title').sortable().searchable().link('detail').limit(60),
C.badge('status')
.label('Status')
.sortable()
.colors({ draft: 'gray', review: 'amber', published: 'green' }),
C.relation('author.fullName').label('Author').sortable('author.fullName').searchable(),
C.relation('tags.name').label('Tags').counts(),
C.count('comments').label('Comments').sortable(),
C.date('publishedAt').label('Published').sortable().since(),
C.text('slug').label('Slug').searchable().toggleable(true),
])examples/blog-admin/app/adonia/resources/post_resource.ts
C.relation('author.fullName')keys the rowauthor.fullName— the dotted key travels to the client verbatim — and preloadsauthor.C.relation('tags.name')over a many-to-many serializes an array of names;.counts()renders it as “Design +3”.C.count('comments')compiles to awithCountsub-select. Its row key iscommentsbut its SQL alias iscomments_count, and.sortable()knows to order by the alias — sorting by the row key would name a column that does not exist..toggleable(true)startsslughidden. Every column is toggleable by default, so this call only ever means “start collapsed”..link('detail')makes the cell the row’s link. Put it on the column a human recognises the row by.
The query budget
The index costs a fixed number of queries regardless of row count: one for
the pagination count, one for the page itself (with every withCount folded in
as a correlated sub-select), and one preload per relation. Nothing is fetched
per row — a cell reads only materialized data ($attributes, $preloaded,
$extras), so no traversal can wake a lazy relation. That property is asserted
in examples/blog-admin/tests/functional/adonia_pipeline.spec.ts.
This is also why a computed column may not be sortable() or searchable():
there is no SQL column behind it.
Sorting, search and filters
t.filters([
Filter.select('status').label('Status').options(STATUS_OPTIONS),
Filter.relation('author').label('Author').searchable().optionLabel('fullName'),
Filter.dateRange('publishedAt').label('Published'),
])
.defaultSort('publishedAt', 'desc')
.searchPlaceholder('Search posts…')
.perPageOptions([10, 25, 50])searchPlaceholder is what makes the search input appear at all — no
placeholder, no box. defaultSort names a column key, which the sort stage
maps through that column’s sortColumn.
Search runs over every column that declared .searchable(). For
author.fullName that means an orWhereHas('author', …) sub-query rather than
a join, so searching cannot multiply rows or corrupt the paginator total:
curl '/admin/posts?q=Ada+Author' # matches posts whose AUTHOR is AdaTerms are AND-ed, targets are OR-ed, and the whole predicate sits inside one
outer where(…) group — without that grouping a leading or would associate
with the next stage’s filters and search would widen past an active filter.
Filters are declared, validated, then applied. Each filter’s valueSchema()
parses the query string value; a value that fails is dropped before apply()
ever runs.
| Factory | Query string | SQL |
|---|---|---|
Filter.select('status') |
filters[status]=published |
where status = 'published' |
Filter.relation('author') |
filters[author][]=3 |
whereHas('author', q => q.whereIn('users.id', [3])) |
Filter.dateRange('publishedAt') |
filters[publishedAt][from]=2026-01-01 |
where published_at >= '2026-01-01' |
::: info Relation filters need their options supplied
compileTableDescriptor does not attach a urls.options block to filter nodes
yet, so a Filter.relation has no way to fetch choices on its own. It applies
correctly when a value arrives, and you can hand it a static list with
.withProps({ options: [...] }) until the endpoint lands.
:::
Unknown input is dropped, never rejected
Sort keys, filter keys, column keys and per-page values are checked against allowlists derived from the declared table. Anything not on one is silently ignored:
curl '/admin/posts?sort=-passwordHash&filters[secret]=1&trashed=only&perPage=999'still answers 200 with the ordinary first page at the default 25 per page.
This is a deliberate rule, not laxity: a panel URL is user-editable and
bookmarkable, and an error page for a stale query string helps nobody. The flip
side is that a key you did declare really applies — both halves are asserted
in adonia_pipeline.spec.ts.
Next: authorization.