---
title: "Filters & search"
description: "Add allowlisted filters, full-text search, and stable query-string state."
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.

# Filters & search

This guide documents the filter DSL, allowlist rules, search drivers, query-string
contract, and global search against the frozen
[protocol §3/§8](/protocol/v1) filter registry.

The code is `packages/core/src/table/filter.ts`, `table/filters/*`,
`table/search/*`, the `filters` / `search` / `softDeleteScope` stages in
`table/stages/`, and the query-string parser in `table/pipeline.ts`. Everything
named below is re-exported from `@adonia/core`.

Where these stages run relative to one another is
[the index query pipeline](/query-pipeline); what the browser does with the
resulting URL is [the index table](/index-table). Neither is restated here.

## Declaring filters

Filters are declared on the resource's `table()` hook, beside its columns.
There is no separate `filters()` hook and no `static filters` — `t.filters([…])`
is the only entry point.

```ts
import { BaseResource, C, F, Filter, TableBuilder } from '@adonia/core'
import type { SchemaComponent } from '@adonia/core'
import Post from '#models/post'

const STATUSES = [
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
  { value: 'published', label: 'Published' },
]

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(): SchemaComponent[] {
return [F.text('title').required()]
  }

  override table(t: TableBuilder): TableBuilder {
return t
  .columns([
    C.text('title').sortable().searchable(),
    C.badge('status').sortable(),
    C.relation('author.fullName').searchable('author.fullName'),
    C.date('publishedAt').sortable(),
  ])
  .filters([
    Filter.select('status').options(STATUSES),
    Filter.ternary('featured'),
    Filter.dateRange('publishedAt'),
    Filter.numberRange('readingMinutes').label('Reading time'),
    Filter.relation('tags').searchable(),
    Filter.ternary('trashed').trashed(),
  ])
  .searchPlaceholder('Search posts…')
  }
}
```

`table()` is optional, and declaring it **fully replaces** §7.5 schema
derivation. Filters are the part of the table with no derived fallback:
`resolveTable` reads `declaredFilters` off the builder and nothing else, so a
resource without a `table()` has no filters at all — columns it will still
derive from the schema, filters it will not invent.

### `Filter` is the namespace *and* the base class

`@adonia/core` exports one `Filter` binding. It is the abstract base class with
the §8.2 factories attached to the class object (`table/filters/index.ts`), so
`Filter.select(…)` and `class ProximityFilter extends Filter` both work and
`instanceof Filter` keeps meaning "is a filter".

| Factory | Class | Wire `type` | Validated value |
| --- | --- | --- | --- |
| `Filter.select(key)` | `SelectFilter` | `select-filter` | one declared option |
| `Filter.multiSelect(key)` | `MultiSelectFilter` | `multi-select-filter` | non-empty array of declared options |
| `Filter.ternary(key)` | `TernaryFilter` | `ternary-filter` | `yes` \| `no` \| `any` |
| `Filter.dateRange(key)` | `DateRangeFilter` | `date-range-filter` | `{ from?, to? }`, ISO-8601 |
| `Filter.numberRange(key)` | `NumberRangeFilter` | `number-range-filter` | `{ min?, max? }` |
| `Filter.relation(key, relation?)` | `RelationFilter` | `relation-filter` | non-empty array of related keys |
| `Filter.custom(key, schema, apply)` | `CustomFilter` | `custom-filter`, overridable | whatever `schema` accepts |

`AttributeFilter` and `OptionFilter` are exported too, but they are abstract:
they are the bases a plugin extends, not factories. There is no
`Filter.attribute()`.

## What every filter carries

The base class fixes four things and leaves two abstract:

| Member | Kind | What it does |
| --- | --- | --- |
| `key` | readonly | the query-string key: `filters[<key>]` |
| `type` | getter | the wire type, from the subclass's `static type` |
| `.label(text)` | builder | sets `props.label` |
| `.withProps({…})` | builder | merges arbitrary JSON-safe props (protocol §10: the prop set is open) |
| `toDescriptor()` | method | compiles the wire node |
| `valueSchema()` | **abstract** | the Vine mini-schema for one `filters[<key>]` value |
| `apply(query, value, ctx)` | **abstract** | the query mutation, run only with a validated value |

`toDescriptor()` emits `{ type, key, props }` — filter nodes carry `key` the way
field nodes do — and, when no `.label()` was set, derives one from the key by
splitting camelCase and `-`/`_`/`.` then title-casing:

```jsonc
// Filter.numberRange('readingMinutes')
{
  "type": "number-range-filter",
  "key": "readingMinutes",
  "props": { "label": "Reading Minutes" }
}
```

`compileTableDescriptor` maps every declared filter through `toDescriptor()` and
adds nothing: filter nodes have no `urls` block today, so a filter that needs
options on the wire must carry them in `props`.

### Validation happens twice, on purpose

The parser (`parseIndexQueryState`) is synchronous — route helpers and link
builders call it with no event loop to await — while Vine validation is
asynchronous. So the two halves are split:

1. **Shape**, in the parser: bracket decoding, a prototype-pollution guard, a
   depth cap, and empty-value pruning. The result is a `JsonValue`.
2. **Semantics**, in the `filters` stage: the value is run through
   `vine.compile(filter.valueSchema())`. A value that fails is skipped exactly
   like an unknown key — `E_VALIDATION_ERROR` is swallowed, anything else
   rethrows, because a non-validation throw is a bug in the filter's own schema.

Compiled validators are memoized in a `WeakMap` keyed by the `Filter` *instance*,
not by its key: resources are constructed per request but filter objects are
declared once, so the cache is shared where the instance is and leaks nowhere
else. The stage iterates in **declaration order**, not query-string order, so
the emitted SQL is stable for a given filter set.

## Column-bound filters

`SelectFilter`, `MultiSelectFilter`, `TernaryFilter`, `DateRangeFilter` and
`NumberRangeFilter` all extend `AttributeFilter`, which owns the column half:

- the filter key doubles as the column — `Filter.select('status')` narrows
  `status`;
- `.column(name)` splits the two apart when the wire key and the SQL column must
  differ;
- a **dotted** target compiles to nested `whereHas` sub-queries, never a join.

That last point is why a filter may traverse a `hasMany` where a sort may not: a
sub-query cannot multiply rows, so neither the page contents nor the paginator's
total can be corrupted by the cardinality of the relation.

<!-- @sample-preamble
import vine from '@vinejs/vine'
import { Filter, conditions } from '@adonia/core'
-->

```ts
// where exists (select … from users where users.email like …)
Filter.select('authorEmail').column('author.email')
```

## The built-ins

### `Filter.select` — one of a declared set

```ts
Filter.select('status').options([
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
  { value: 'published', label: 'Published' },
])
```

`.options()` takes `FieldOption`s (`value`, `label`, optional `color`, `icon`,
`disabled`) and projects them to `props.options`. The set is also the validation
domain.

- **`valueSchema()`** — `vine.enum([...declared values as strings])`, pre-parsed
  with `String(value)` so a JSON body carrying `3` is compared against `'3'`
  rather than rejected on type. With **no** options declared it degrades to
  `vine.string().trim().minLength(1)`, for a filter whose choices are resolved
  at runtime.
- **`apply()`** — `where <column> = <value>`.

The comparison is built with the *declared* value, not the wire string:
`typedValue()` maps the validated member back through the option set, so
`Filter.select('categoryId').options([{ value: 3, label: 'Engineering' }])`
emits `where category_id = 3`. On Postgres this matters — `integer = text` is a
type error there, not a coercion.

### `Filter.multiSelect` — any of a declared set

```ts
Filter.multiSelect('status').options([
  { value: 'draft', label: 'Draft' },
  { value: 'review', label: 'In review' },
])
```

- **`valueSchema()`** — `vine.array(member).parse(v => Array.isArray(v) ? v : [v]).minLength(1)`.
  The pre-parse accepts both wire shapes:
  `filters[status][]=draft&filters[status][]=review` and the scalar
  `filters[status]=draft` a shared link may carry.
- **`apply()`** — `where <column> in (<values>)`, again with declared types.

`minLength(1)` is deliberate. An emptied selection means "not filtering", so it
must be *dropped*; compiling it to `where … in ()` would return no rows.

### `Filter.ternary` — yes / no / any

```ts
Filter.ternary('featured')
```

- **`valueSchema()`** — `vine.enum(['yes', 'no', 'any'])`.
- **`apply()`** — `yes` → `where <column> = true`, `no` → `where <column> = false`,
  `any` → nothing.

The third state is the *absence* of the key, so a ternary that reaches `apply`
has already chosen a side; `any` is accepted only so the client can spell "I
cleared it" explicitly.

`.trashed()` turns the control into the soft-delete scope selector — see
[the trashed scope](#soft-deletes-the-trashed-scope) below.

### `Filter.dateRange` — an inclusive ISO range

```ts
Filter.dateRange('publishedAt')
```

Wire shape:
`filters[publishedAt][from]=2026-01-01&filters[publishedAt][to]=2026-03-31`.

- **`valueSchema()`** — `vine.object({ from: <iso>.optional(), to: <iso>.optional() })`,
  where `<iso>` is `vine.string().trim().regex(…)` accepting `2026-03-01`,
  `2026-03-01T09:30` and `2026-03-01T09:30:00.000Z`. Both halves optional, so a
  one-sided range works; the parser has already turned `…[from]=` into an absent
  key rather than `''`.
- **`apply()`** — `where <column> >= from` and/or `where <column> <= to`; both
  absent applies nothing.

Two behaviours worth knowing. Bounds stay **strings** all the way into the
binding: parsing them to `Date` would impose the server's timezone on a value
the user picked in theirs, silently shifting "1 March" by a day for anyone west
of UTC. And a **date-only** upper bound is extended to `T23:59:59.999`, because
`to=2026-03-01` against a datetime column would otherwise exclude everything
after midnight on the day the user asked to include.

### `Filter.numberRange` — an inclusive numeric range

```ts
Filter.numberRange('readingMinutes')
```

Wire shape: `filters[readingMinutes][min]=5&filters[readingMinutes][max]=20`.

- **`valueSchema()`** — `vine.object({ min: vine.number().optional(), max: vine.number().optional() })`.
  `vine.number()` coerces the numeric strings a query string delivers and
  rejects everything else, so `…[min]=lots` is dropped rather than compiled to a
  comparison against `NaN` — which every dialect answers with "no rows".
- **`apply()`** — `where <column> >= min` and/or `where <column> <= max`.

Note the key names: this filter takes `min`/`max`, `dateRange` takes `from`/`to`.

### `Filter.relation` — related records

```ts
Filter.relation('author').searchable().optionLabel('fullName')
Filter.relation('writtenBy', 'author')
```

`Filter.relation(key, relation?)` — the second argument names the relation when
it differs from the wire key; otherwise the key *is* the relation. Wire shape:
`filters[author][]=3&filters[author][]=7`, or the scalar `filters[author]=3`.

- **`valueSchema()`** — `vine.array(vine.string().trim().minLength(1))`, scalar
  pre-parsed to an array, `minLength(1)`. Keys stay **strings**: the database
  compares them against the related primary key, and coercing to a number would
  break UUID and slug keys.
- **`apply()`** — `whereHas(<relation>, q => q.whereIn('<related table>.<pk>', ids))`.
  Always a sub-query, never a join, even for `belongsTo` where
  `where author_id in (…)` would be marginally cheaper: one uniform shape is
  correct for every cardinality, and the author does not have to know the
  relation's kind to declare the filter.

The related primary key is table-qualified, because a `manyToMany` sub-query
joins the pivot table and a bare `id` would be ambiguous there. A relation the
model does not have applies **nothing** — a stale bookmark pointing at a renamed
relation renders the table, it does not throw.

`.searchable(enabled = true)` sets `props.searchable` and `.optionLabel(attr)`
sets `props.optionLabel`. Be aware of the current gap: the client's
`relation-filter` component fetches options from `node.urls.options` and falls
back to inline `props.options`, but `compileTableDescriptor` attaches no `urls`
block to filter nodes yet (the compiler must supply it because assembling the URL needs
the panel mount). Until it
does, give a relation filter its choices with
`.withProps({ options: [/* … */] })`.

### `Filter.custom` — your schema, your predicate

The escape hatch. You supply the mini-schema and the mutation; you get the same
§8.3 guarantee as a built-in — the value is validated and dropped on failure
before your callback is ever called.

```ts
Filter.custom('hasCover', vine.enum(['yes', 'no']), (query, value) => {
  const rows = conditions(query)
  if (value === 'yes') rows.whereNotNull('posts.cover_image')
  else rows.whereNull('posts.cover_image')
})
```

`conditions(query)` is the exported view onto the predicate surface of the Lucid
builder: `where`, `andWhere`, `orWhere`, `whereRaw`, `orWhereRaw`, `whereNull`,
`whereNotNull`, `whereIn`, `whereHas`, `orWhereHas`. `query` itself is the full
`ModelQueryBuilderContract` if you need more than that.

`apply` may be async, and the stage awaits it through `pendingMutation` rather
than a bare `await` — a callback written as `(query, value) => query.where(…)`
returns the *builder*, and awaiting a Lucid builder executes it mid-pipeline.

`.as(type)` overrides the client component the node renders as; the protocol §8
filter namespace is open, and the default `custom-filter` falls back to the
client's unknown-component behaviour rather than throwing.

<!-- @sample-preamble-reset -->

## The query-string contract

`parseIndexQueryState` accepts either the decoded bag from `ctx.request.qs()` or
a raw query string, and is the only doorway between the request and the query.

| Query key | State field | Parsing |
| --- | --- | --- |
| `q` | `search` | trimmed; `null` when empty or shorter than `search.minQueryLength` (default `2`) |
| `filters[<key>]` | `filters` | key must be in the filter allowlist; value sanitized to a `JsonValue` |
| `filters[<key>][from]`, `[to]`, `[min]`, `[max]` | `filters` | nested object, one bracket deeper |
| `filters[<key>][]` | `filters` | repeated entries accumulate into an array |
| `sort` | `sort` | comma-separated, `-` prefixes desc, duplicates dropped |
| `columns` | `columns` | comma-separated; `null` (= resource default) when nothing survives |
| `trashed` | `trashed` | `with` \| `only`; anything else is `default` |
| `page` | `page` | `/^[1-9][0-9]*$/`; `1` when absent or malformed |
| `perPage` | `perPage` | must be a member of the effective `perPageOptions` |
| `cursor` | `cursor` | cursor-mode resources only, opaque here |

Bracket decoding is done by the parser itself rather than by a query-string
library, and it is deliberately narrow:

- nesting deeper than **4** brackets is dropped whole;
- `__proto__`, `constructor` and `prototype` are refused as path segments, at
  every depth, in both the raw-string and decoded-bag paths;
- repeated scalar keys are last-wins, repeated `key[]` keys accumulate;
- `a[][b]=x` is not a §10.2 shape and is dropped.

**Empty is unset.** `sanitizeFilterValue` drops empty strings, and containers
that end up empty drop with them: `filters[status]=` yields no `status` entry,
and `filters[publishedAt][from]=&filters[publishedAt][to]=2026-06-30` yields
`{ publishedAt: { to: '2026-06-30' } }`. Only strings, finite numbers, booleans,
arrays and plain objects survive; `null`, functions and class instances are not
query input.

## Unknown input is dropped, never rejected

This allowlist rule is absolute: **an unknown
filter key, an unknown sort key, an unknown column key and an out-of-range
`perPage` are silently discarded**. Not an error, not a 4xx, not passed through
to the query. A stale bookmark or a hand-edited URL must render the default
table.

The whole enforcement is four `continue`s and a ternary, all in
`table/pipeline.ts`. The excerpts below are those loops, with the surrounding
locals declared:

<!-- @sample-preamble
import vine, { errors as vineErrors } from '@vinejs/vine'
import { Filter, pendingMutation } from '@adonia/core'
import type {
  IndexQuery,
  IndexQueryState,
  JsonValue,
  PipelineContext,
  QueryAllowlists,
  SortInstruction,
} from '@adonia/core'

declare const allowlists: QueryAllowlists
declare const rawFilters: Record<string, unknown>
declare function sanitizeFilterValue(value: unknown, depth: number): JsonValue | undefined
declare const raw: string
declare const sortable: ReadonlySet<string>
declare const allowed: ReadonlySet<string>
declare const requestedPerPage: number | null
declare const perPageOptions: readonly number[]
declare const fallbackPerPage: number
declare const declaredFilters: ReadonlyMap<string, InstanceType<typeof Filter>>
declare const state: IndexQueryState
declare const query: IndexQuery
declare const context: PipelineContext
-->

```ts
// filters — the key must be declared, and the value must survive sanitization
const filters: Record<string, JsonValue> = {}
for (const [key, value] of Object.entries(rawFilters)) {
  if (!allowlists.filters.has(key)) continue
  const sanitized = sanitizeFilterValue(value, 1)
  if (sanitized !== undefined) filters[key] = sanitized
}
```

```ts
// sort — the column must be sortable, and each key counts at most once
const instructions: SortInstruction[] = []
const seenSort = new Set<string>()
for (const token of raw.split(',')) {
  const trimmed = token.trim()
  if (trimmed.length === 0) continue
  const descending = trimmed.startsWith('-')
  const column = (descending ? trimmed.slice(1) : trimmed).trim()
  if (column.length === 0 || seenSort.has(column) || !sortable.has(column)) continue
  seenSort.add(column)
  instructions.push({ column, direction: descending ? 'desc' : 'asc' })
}
```

```ts
// columns — the key must exist on the table
const columns: string[] = []
const seenColumn = new Set<string>()
for (const token of raw.split(',')) {
  const key = token.trim()
  if (key.length === 0 || seenColumn.has(key) || !allowed.has(key)) continue
  seenColumn.add(key)
  columns.push(key)
}
```

```ts
// perPage — membership, not a range check
const perPage =
  requestedPerPage !== null && perPageOptions.includes(requestedPerPage)
? requestedPerPage
: fallbackPerPage
```

…plus the one in the `filters` stage, which applies the same rule to a value
that is structurally fine but semantically wrong:

```ts
for (const [key, filter] of declaredFilters) {
  const rawValue = state.filters[key]
  if (rawValue === undefined) continue
  const validator = vine.compile(filter.valueSchema())

  let value: JsonValue
  try {
value = (await validator.validate(rawValue, { meta: undefined })) as JsonValue
  } catch (error) {
if (error instanceof vineErrors.E_VALIDATION_ERROR) continue
throw error
  }

  await pendingMutation(filter.apply(query, value, context))
}
```

<!-- @sample-preamble-reset -->

The allowlists themselves come from `allowlistsFor(table)`: `columns` is every
column key, `sortable` is the keys with a `sortColumn`, `searchable` is the keys
with at least one search column, `filters` is `table.filters.map(f => f.key)`,
and `perPageOptions` is the resource's list. They hold **column keys**, not SQL
columns — `?sort=` and `?columns=` speak the wire vocabulary, and the stages map
a key to SQL afterwards. A column hidden from the request by `canSee` never
enters them, so a user who cannot see a column cannot sort by it either.

Nothing in this file throws. The returned state satisfies, by construction:
`sort[].column ⊆ sortable`, `keys(filters) ⊆ filters`, `columns ⊆ columns` or
`null`, `perPage ∈ perPageOptions`, `page ≥ 1`, `trashed ∈ {default, with, only}`.

```ts
import { ADONIA_CONFIG_DEFAULTS, parseIndexQueryState } from '@adonia/core'
import type { QueryAllowlists } from '@adonia/core'

const allowlists: QueryAllowlists = {
  sortable: new Set(['title', 'publishedAt']),
  searchable: new Set(['title']),
  filters: new Set(['status', 'publishedAt']),
  columns: new Set(['title', 'status', 'publishedAt']),
  perPageOptions: [10, 25, 50],
}

const state = parseIndexQueryState(
  '?q=ada&sort=-publishedAt,internalScore' +
'&filters[status]=published&filters[internalScore]=9' +
'&perPage=100000&trashed=maybe&columns=title,ssn',
  { allowlists, defaults: ADONIA_CONFIG_DEFAULTS.defaults }
)

// state.search  === 'ada'
// state.sort    -> [{ column: 'publishedAt', direction: 'desc' }]  internalScore gone
// state.filters -> { status: 'published' }                         internalScore gone
// state.columns -> ['title']                                       ssn gone
// state.perPage === 25                                             100000 -> defaults.perPage
// state.trashed === 'default'                                      'maybe' unrecognized
```

The client mirrors all of this in `parseTableQuery`; see
[the index table](/index-table).

## Search

### Declaring what is searchable

Search has its own allowlist, declared per column:

```ts
import { C } from '@adonia/core'

C.text('title').searchable() //                      searches the `title` column
C.relation('author.fullName').searchable('author.fullName') // searches through the relation
C.text('name').searchable('first_name', 'last_name') //        one key, two SQL columns
```

`.searchable(...columns)` defaults to `[this.key]` when called bare, sets
`props.searchable = true` for the client, and puts the column **key** into
`allowlists.searchable`. A column with no search columns is not searchable, and
`?q=` can never reach it.

`?q=` itself is gated in the parser: the value is trimmed, and anything shorter
than `search.minQueryLength` (config default `2`) becomes `null`, which makes
the `search` stage a no-op.

### From allowlist key to SQL target

`resolveSearchTargets(model, searchable, columns)` — exported, because the §10.4
global-search endpoint needs the same expansion without running the index
pipeline — turns each allowlist key into zero or more `SearchTarget`s:

```ts
import type { SearchTarget } from '@adonia/core'

const targets: readonly SearchTarget[] = [
  { relation: null, column: 'posts.title' },
  { relation: 'author', column: 'users.full_name' },
]
```

A key expands to its column's declared search columns, or to itself when no
`Column` matches it. Each path is walked against the model's relation metadata:
a local attribute becomes `<table>.<column>`, a dotted path qualifies against
the **related** table and records the relation prefix. Any hop that does not
resolve — a renamed relation, a column dropped in a migration — is skipped, per
§8.3. Duplicates collapse. The result is memoized per allowlist identity, so
resolution happens once, not per request.

Doing this in the stage is what keeps drivers portable: a driver receives
`SearchTarget[]` and never imports Lucid.

### The driver contract

```ts
import type { SearchDriver } from '@adonia/core'

export type Contract = Pick<SearchDriver, 'name' | 'search'>
// name: string
// search(resource, term, context): SearchDriverResult | Promise<SearchDriverResult>
```

`context` is `SearchDriverContext` — the whole `PipelineContext` (`ctx`,
`resource`, `state`, `allowlists`, `hints`, `tenant`, `dialect`) plus the
resolved `targets`. `term` is already trimmed and already past
`minQueryLength`.

`SearchDriverResult` is a three-way answer:

| Return | Meaning | What the stage does |
| --- | --- | --- |
| `readonly (string \| number)[]` | "these primary keys match" | `whereIn('<table>.<pk>', ids)` |
| `(query) => void \| Promise<void>` | "apply this to the query" | runs it through `pendingMutation` |
| `null` | "nothing to narrow on" | leaves the query untouched |

The distinction between `[]` and `null` is load-bearing. `[]` is a *result* — "I
searched and found nothing" — and yields an empty page. `null` is an *absence*
and yields every row.

### The `database` driver

Registered under the name `database` by importing `table/search/index.ts`; it is
the default of `config.search.driver`. Its shape is fixed by §8.4:

1. Split `term` on whitespace. No searchable targets, or no non-empty terms →
   return `null`.
2. Escape `%`, `_` and `!` in each term and wrap it in `%…%`.
3. Return a mutation that opens **one** outer `where(…)` group, containing one
   `andWhere(…)` group per term, containing one `or` alternative per target.

So it is **AND across terms, OR across columns**: `?q=ada lovelace` asks for rows
where *some* searchable column contains "ada" and *some* searchable column
contains "lovelace" — not necessarily the same one, which is what makes a name
split across `first_name`/`last_name` findable from a single box.

```sql
-- ?q=ada lovelace, searchable: title + author.fullName, on Postgres
where (
  (posts.title ilike '%ada%' escape '!'
    or exists (select * from users where … and users.full_name ilike '%ada%' escape '!'))
  and (posts.title ilike '%lovelace%' escape '!'
    or exists (select * from users where … and users.full_name ilike '%lovelace%' escape '!'))
)
```

Three details are not cosmetic:

- **The outer group.** Without it, the leading `or` of the first alternative
  would associate with whatever the `filters` stage appends next, and a search
  would *widen* the result set past an active filter instead of narrowing it.
  That is a data-exposure bug (§19), not a formatting preference.
- **The dialect branch.** `context.dialect === 'postgres'` emits
  `?? ilike ? escape '!'`; every other dialect emits
  `lower(??) like lower(?) escape '!'`, the portable spelling. MySQL's default
  collation is already case-insensitive, so `lower()` is redundant there but
  never wrong.
- **The escape character is `!`, not `\`.** MySQL treats a backslash as a string
  escape, so the SQL literal `'\'` is a syntax error there; `'!'` is inert
  everywhere. A user typing `50%` gets the two characters, not a wildcard.

A target with a relation prefix is emitted as `orWhereHas`, nesting one
`whereHas` per hop for a multi-segment path — never a join, so a `hasMany`
search cannot multiply rows.

### Registering your own driver

`defineSearchDriver` is an identity function whose only job is to typecheck the
literal at its declaration site. `registerSearchDriver` puts it in the
process-wide registry, **replacing** any driver of the same name — so overriding
the built-in `database` driver needs no new name and no resource edits.

```ts
import { defineSearchDriver, registerSearchDriver } from '@adonia/core'
import type { ResourceClass } from '@adonia/core'

interface EngineResponse {
  hits: { id: number }[]
}

/** An external engine that answers with primary keys. */
export const meilisearch = registerSearchDriver(
  defineSearchDriver({
name: 'meilisearch',

async search(resource, term, context) {
  const { slug } = resource.constructor as ResourceClass
  const response = await fetch(`http://127.0.0.1:7700/indexes/${slug}/search`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      q: term,
      limit: context.state.perPage * 10,
      filter: context.tenant === undefined ? [] : [`tenant = ${String(context.tenant)}`],
    }),
  })

  if (!response.ok) return null
  const body = (await response.json()) as EngineResponse
  return body.hits.map((hit) => hit.id)
},
  })
)
```

Register at boot — a provider's `boot()`, or `start/adonia.ts` — then point the
config at it:

```ts
import { defineConfig } from '@adonia/core'

export default defineConfig({
  search: { driver: 'meilisearch', minQueryLength: 2 },
})
```

Resolution is the one place in this document that is **not** a silent drop.
`resolveSearchDriver(name)` throws `InvalidConfigException`
(`E_ADONIA_INVALID_CONFIG`) listing the registered names when nothing matches. A
driver name is developer configuration, not user input, and a typo that quietly
disabled search on every resource would be far worse than a failed boot. The
pipeline resolves the driver when it is built, so the throw lands at boot.
`hasSearchDriver(name)` and `searchDriverNames()` are available for a
`doctor`-style check ([diagnostics](/doctor)).

## Soft deletes: the trashed scope

The soft-delete scope is decided once, in the `softDeleteScope` stage, which
runs **first** — before `search`, `filters` and `sort` — so every later stage
narrows the same row set.

| Scope | Predicate | Selected by |
| --- | --- | --- |
| `default` | `where <table>.<col> is null` | nothing (the default) |
| `with` | *(none)* | `?trashed=with`, or `filters[<key>]=with` |
| `only` | `where <table>.<col> is not null` | `?trashed=only`, or `filters[<key>]=only` |

`resolveTrashedScope(state, filters)` picks it:

1. Scan the declared filters for a `TernaryFilter` with `.trashed()` set. If its
   sanitized value maps through the scope table, that wins — it is the control
   the resource actually rendered, and its key already passed the allowlist.
2. Otherwise fall back to `state.trashed`, so a resource can honour the §10.2
   `?trashed=` key without declaring a filter for it.

A `.trashed()` ternary widens its own vocabulary and accepts the plain ternary
words as aliases, so one UI control can emit either:

| Value | Scope |
| --- | --- |
| absent, `default`, `no` | `default` |
| `with`, `any` | `with` |
| `only`, `yes` | `only` |

It also sets `props.variant = 'trashed'` and the two options
`with` / `only`, matching the frozen protocol fixture.

A trashed ternary applies **no predicate of its own** — its `apply()` returns
immediately. `softDeleteScope` has already consumed the value, and emitting the
predicate twice would be redundant at best and, after `?trashed=only`,
contradictory.

The scope column is table-qualified because the `sort` stage may `LEFT JOIN` a
related table with its own `deleted_at`; unqualified, that is an error on MySQL
and, worse, the silently wrong column elsewhere. Detection
(`detectSoftDeletes`) looks for a `deletedAt`/`deleted_at` model attribute and
is overridable per resource with `static softDeletes`. A resource without soft
deletes gets a shared no-op stage and pays nothing.

## Global search (`GET /search`)

The panel's ⌘K palette reads one endpoint, registered as
`adonia.<panelId>.search` inside the `adonia.panel-access` middleware group and
routed **before** `/:resource`, so a panel may still own a resource slugged
`search`.

The §10.4 envelope is final:

```ts
import type { GlobalSearchResponse } from '@adonia/core'

const body: GlobalSearchResponse = {
  groups: [
{
  resource: 'posts',
  label: 'Posts',
  results: [
    {
      id: 12,
      title: 'Designing the pipeline',
      subtitle: 'Published 12 Jun 2026',
      url: '/admin/posts/12',
      thumbnail: '/uploads/covers/12.jpg',
    },
  ],
},
  ],
}
```

`subtitle` and `thumbnail` are optional; `groups` is one entry per searched
resource.

The controller does three things: `panelOf(ctx)` — which throws
`UnknownResourceException` if no panel resolved for the request — then
`noStore(ctx)`, which sets `Cache-Control: no-store`, then a JSON response.
Search results are per-user and authorization-shaped, so they must never sit in
a shared or browser cache.

The handler walks registered resources in panel order and skips a resource when
`globallySearchable === false`, `viewList` is denied, or its resolved searchable
allowlist is empty. An explicit `static globallySearchable = [...]` replaces the
derived table allowlist; otherwise the index plan's searchable columns are reused.
Each query runs through the ordinary index scope and pipeline, is capped by
`search.globalSearchLimitPerResource` (default `5`), and then drops records denied
by `view`. Empty result groups are omitted. Hit titles use `recordTitle` with the
primary key as fallback, and hit URLs are built server-side for the active panel.
Queries shorter than `search.minQueryLength` return `{ groups: [] }` without
touching resource models.

Per-resource search on the index (`?q=`) is unaffected by any of this; it runs
through the pipeline's `search` stage described above.

Source: https://adonia.pages.dev/guide/filters-and-search/index.mdx
