Skip to content

Querying: search, filters, sorting, pagination

Every list endpoint in Ion Drive speaks the same query language. It is designed to be readable in a URL, easy to build by hand, and consistent across REST, GraphQL, and MCP. The @ion-drive/client SDK builds these queries for you in a type-safe way.

All examples below use the REST list endpoint:

GET /api/v1/data/:object

The response is always the same envelope:

{
"data": [ /* records */ ],
"pagination": {
"page": 1, "pageSize": 25, "totalCount": 42,
"totalPages": 2, "hasNextPage": true, "hasPreviousPage": false
}
}

search (alias q) matches a term against all text-like columns of the object — text, email, url, slug, and single/multi enum fields — using a case-insensitive ILIKE '%term%' OR across those columns.

GET /api/v1/data/contacts?search=acme
GET /api/v1/data/contacts?q=acme # shorthand alias

% and _ in the term are matched literally (they are escaped), so a search for 50% does not become a wildcard. Search combines with filters via AND.


Filter on a specific property with the field[operator]=value syntax. A bare field=value is shorthand for equality.

GET /api/v1/data/contacts?status[neq]=archived&age[gte]=21
GET /api/v1/data/contacts?status=active # same as status[eq]=active
OperatorMeaningAliasesExample
eqequals=, ==status[eq]=active
neqnot equalne, !=, <>status[neq]=archived
gtgreater than>age[gt]=21
gtegreater or equal>=age[gte]=21
ltless than<age[lt]=65
lteless or equal<=age[lte]=64
likecase-sensitive containsname[like]=Jo
ilikecase-insensitive containscontainsname[ilike]=jo
inin a comma-separated listtier[in]=gold,platinum
ninnot in a listnotintier[nin]=free
is_nullvalue is NULLnull, isnulldeleted_at[is_null]=true
is_not_nullvalue is not NULLnotnulldeleted_at[is_not_null]=true

Operators are case-insensitive. name[NEQ]=John and name[neq]=John are identical — so are the aliases: age[GT]=30, age[>]=30, and age[gt]=30 all mean the same thing.

Values arrive as strings and are coerced automatically:

  • true / false → boolean
  • null → null
  • numeric strings → number (e.g. age[gt]=21 compares as an integer)
  • everything else stays a string (dates included — Postgres compares them)

For in/nin, each comma-separated item is coerced individually: age[in]=18,21,30 becomes [18, 21, 30].

For is_null / is_not_null the value is ignored — any placeholder works (the SDK emits true).

Repeat parameters to AND several conditions together — including two conditions on the same field to express a range:

GET /api/v1/data/orders?total[gte]=100&total[lte]=500&status[neq]=cancelled

sort takes a comma-separated list of fields. Prefix a field with - for descending. Sort keys are applied in order (first is primary).

GET /api/v1/data/contacts?sort=-created_at # newest first
GET /api/v1/data/contacts?sort=status,-created_at # by status, then newest

Without sort, results default to created_at descending.


Two interchangeable interfaces:

  • Page-basedpage (1-based) and pageSize.
  • Offset-based (Supabase/PostgREST-style) — limit and offset. When present these take precedence; the response’s page/pageSize are derived from them so the metadata stays coherent.

pageSize/limit default to 25 and are clamped to a maximum of 100. totalCount reflects the filters and search, so it is safe to drive a pager from it.

GET /api/v1/data/contacts?page=2&pageSize=50 # page-based
GET /api/v1/data/contacts?limit=50&offset=50 # offset-based (same window)

  • select — comma-separated list of fields to return (projection).
  • expand — comma-separated relation keys to include as attached records.
GET /api/v1/data/contacts?select=id,full_name,email
GET /api/v1/data/contacts?expand=company,tags
GET /api/v1/data/companies?expand=contacts_by_company

A relation key is (Phase 13):

KeyWhereAttaches
<relName>the FK-holding side (many_to_one/one_to_one source, one_to_many target)the related record or null
<relName>either side of a many_to_manythe linked records (array)
<fkObject>_by_<relName>the “one” side (reverse traversal)the FK-holding records (array; single for a reverse one_to_one)

The OpenAPI spec’s expand parameter (and MCP’s get_object tool) list each object’s available keys. Unknown keys are ignored. GraphQL exposes the same keys as nested fields (see graphql.md).


FK-backed links are set through the record itself ({ "company_id": "…" }); many_to_many links are written through the junction endpoints:

POST /api/v1/data/contacts/:id/links/tags { "ids": ["…", "…"] } → { "data": { "added": n } }
DELETE /api/v1/data/contacts/:id/links/tags { "ids": ["…"] } → { "data": { "removed": n } }

Both are idempotent (already-linked / not-linked ids are skipped) and emit data.<object>.linked / data.<object>.unlinked events carrying only the ids that actually changed. SDK: ion.from('contacts').link(id, 'tags', ids) / .unlink(...); MCP: link_records / unlink_records; GraphQL: link_<object>_<rel> / unlink_<object>_<rel> mutations.


@ion-drive/client ships a fluent, awaitable builder inspired by Supabase’s postgrest-js — you never assemble these strings by hand. It normalises operator aliases, encodes values (dates become ISO strings), and joins list values for you. Start from .from(object).select(...), chain filters/modifiers, and await the chain — no terminal call needed.

import { IonDriveClient } from '@ion-drive/client';
const ion = new IonDriveClient({
baseUrl: 'http://localhost:3000',
apiKey: process.env.ION_DRIVE_API_KEY, // optional
});
const contacts = ion.from('contacts');
// Awaiting the chain executes it -> { data, pagination }
const { data, pagination } = await contacts
.select('id, full_name, email') // projection (optional)
.search('acme')
.neq('status', 'archived')
.in('tier', ['gold', 'platinum'])
.gte('created_at', new Date('2020-10-10'))
.order('created_at', { ascending: false })
.range(0, 24); // rows 0..24 (offset-based)
// Single-row terminals
const one = await contacts.select().eq('id', id).single(); // throws unless exactly 1
const some = await contacts.select().eq('email', e).maybeSingle(); // T | null (throws if >1)
const rows = await contacts.select().search('acme').all(); // just the array

Prefer to build only the string (for a fetch you already own)? Use the standalone query() factory:

import { query } from '@ion-drive/client';
const qs = query()
.neq('name', 'John')
.gt('created_at', '2020-10-10')
.search('acme')
.order('created_at', { ascending: false })
.range(0, 24)
.toQueryString();
// "name[neq]=John&created_at[gt]=2020-10-10&search=acme&sort=-created_at&offset=0&limit=25"
await fetch(`${baseUrl}/api/v1/data/contacts?${qs}`);
MethodEmits / does
.where(field, op, value)field[op]=value (accepts aliases)
.eq/.neq/.gt/.gte/.lt/.lte(field, value)the matching operator
.like/.ilike(field, value)contains match
.in(field, [...]) / .nin(field, [...])comma-joined list
.isNull(field) / .isNotNull(field) / .is(field, null)null checks
.not(field, op, value)negation (eq→neq, in→nin, is null→is_not_null)
.match({ a, b })one eq per key
.search(term)search=term
.order(field, { ascending }) / .sort(field, dir)appends to sort=
.limit(n) / .offset(n) / .range(from, to)offset-based paging
.page(n) / .pageSize(n)page-based paging
.expand(...rels) / .select(cols)expansion / projection
await / .list()execute → { data, pagination }
.all() / .first() / .single() / .maybeSingle()execute → rows / one
.toQueryString()the raw query string (no fetch)

The same capabilities are exposed on the other surfaces.

GraphQL — the generated list query accepts search, filter, sort, page/pageSize, and limit/offset:

{
contacts(
search: "acme"
filter: [{ field: "status", operator: neq, value: "archived" }]
sort: [{ field: "created_at", direction: desc }]
page: 1
pageSize: 25
) {
data { id full_name email }
pagination { totalCount totalPages }
}
}

MCP — the query_data tool takes object_name, search, filters, sort, page/page_size, and limit/offset, so an LLM agent searches and filters exactly like the REST and GraphQL clients do.