---
title: "4. The index table"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 4. The index table

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.

<!-- @sample-preamble
import { BaseResource, C, F, Filter } from '@adonia/core'
import type { FieldOption, SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import Post from '#models/post'
const STATUS_OPTIONS: readonly FieldOption[] = []
declare const t: TableBuilder
-->

## Columns

```ts
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),
])
```

<small>`examples/blog-admin/app/adonia/resources/post_resource.ts`</small>

- **`C.relation('author.fullName')`** keys the row `author.fullName` — the
  dotted key travels to the client verbatim — and preloads `author`.
- **`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 a `withCount` sub-select. Its row key is
  `comments` but its SQL alias is `comments_count`, and `.sortable()` knows to
  order by the alias — sorting by the row key would name a column that does not
  exist.
- **`.toggleable(true)`** starts `slug` hidden. 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

```ts
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:

```sh
curl '/admin/posts?q=Ada+Author'   # matches posts whose AUTHOR is Ada
```

Terms 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:

```sh
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](/guide/tutorial/authorization).

Source: https://adonia.pages.dev/guide/tutorial/table/index.mdx
