@ox0/query
v0.8.0
Published
A search-and-filter language for lists of records: plain words, `field:value`, exclusions, number and date comparisons and ranges, and several values per field — without boolean logic to learn. Parses to a JSON-serializable `Query` that a backend, the dat
Readme
@ox0/query
A search-and-filter language for lists of records: plain words, field:value, exclusions,
number and date comparisons and ranges, and several values per field — without boolean logic to
learn. Parses to a JSON-serializable Query that a backend, the data grid, or a URL can carry.
Documentation: https://esturcke.github.io/ox0-docs/query
pnpm add @ox0/queryThere is no @ox0/query stylesheet to import: QueryBar injects its own styles. Like the rest of
ox0, it needs the design tokens (@ox0/tokens/styles.css) loaded once by the app.
Syntax
| Type | Examples |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Text | john smith · "john smith" · -john · name:john · name:john* · name:*john* · name:-john · name:=John · name:-=Jo* · name:ada,grace · name:"Smith, John",ada |
| Numeric | amount:1000 · amount:>1000 · amount:<=50 · amount:10..50 · amount:100.. · amount:5,10 · age:..5,60.. · -age:8..9 |
| Date | created:2026-01-15 · created:2026-01 · created:2026-01..2026-06 · created:-30d · created:<2026 · created:>=2026-01 · created:2026-01,2026-03 · -created:2026-01 |
| Enum | status:active · status:active,pending · status:act* · status:-closed · -status:closed,archived |
| Any | name:* (has a value) · name:-* (empty) |
Plain words match anywhere; a field value matches the whole value unless you add *
("*" in quotes is literal). Matching is case-insensitive; = after a text field's colon (and
after any -) matches case: name:=John doesn't match "john". Different fields AND together;
values within one field OR; plain words AND.
A comma separates the values of one filter, for every type of field; quote a value that contains
one (name:"Smith, John"). Numbers are written without thousands separators — amount:1,000 is
1 or 0. Number and date list items can be ranges (age:..5,60..); comparisons and relative dates
can't be in a list. Numbers and dates are excluded with - before the field (-amount:5),
because amount:-5 means "equals −5".
Usage
import {
defineQuerySchema,
normalizeQuery,
parseQuery,
serializeQuery,
syntaxGuide,
} from "@ox0/query"
const orders = defineQuerySchema({
fields: [
{ id: "customer", type: "text", aliases: ["who"] },
{ id: "amount", type: "numeric" },
{ id: "created", type: "date" },
{ id: "status", type: "enum", values: ["active", "pending", "closed"] },
],
})
const { committed, rejected, diagnostics } = parseQuery("status:active amount:>1000 acme", orders, {
final: true,
})
serializeQuery(committed) // "status:active amount:>1000 acme"
// Tokens from outside the parser (props, saved state): canonical ids, enum casing, slot rule.
const { query } = normalizeQuery(committed, orders)
// Help content with examples from these fields; the UI words each entry from its id and params.
syntaxGuide(orders)Diagnostics carry a kind, a stable reason code, and raw params — never user-facing text.
Shareable links
A query lives in the URL fragment as #0:status:active+amount:%3E100 (readable, hand-editable)
or, when that would pass 200 characters and compression is shorter, #1:<compressed JSON>. The
number is the format: every format ever written stays decodable.
import { useQueryFragment } from "@ox0/query/react"
function Orders() {
// Reads the fragment on load and on back/forward; writes committed changes with replaceState.
const { query, setQuery, diagnostics } = useQueryFragment(orders) // or { key: "orders" }
// …
}Without React: encodeQuery(query) → payload, decodeQuery(payload, schema) → { query,
diagnostics } (never throws; a token that no longer fits the schema stays as an invalid token
that matches nothing, with a diagnostic), and
readQueryFragment / writeQueryFragment for the fragment itself. Field ids end up in links, so
rename a field by moving its old id to previousIds.
QueryBar
import { QueryBar, useQueryFragment } from "@ox0/query/react"
function Orders() {
const { query, setQuery } = useQueryFragment(orders)
return <QueryBar label="Search orders" schema={orders} value={query} onChange={setQuery} />
}Typing builds a pending query; ⌘/Ctrl+Enter or Apply applies it (onChange), Cancel or Escape
drops it, and × clears and applies. apply="live" applies each token as it is completed instead.
Tab completes a suggestion. It takes sizing props (width, maxWidth, flex, …) like other ox0
fields. Wording comes from ox0.query.* messages (defaultQueryMessages). The dropdown and the
help render in a portal, so a container with overflow: hidden doesn't clip them.
To build the filter from values on the page (a "filter by this value" control), edit the pending
query through apiRef with editFieldValue; onPendingChange reports what is pending:
const apiRef = useRef<QueryBarApi>(null)
const include = (status: string) =>
apiRef.current?.edit((pending) =>
editFieldValue(pending, orders, { field: "status", value: status, action: "include" }),
)
// <QueryBar apiRef={apiRef} … />Text that doesn't parse
A token the parser can't read (created:202, gone:x) stays in the query as
{ type: "invalid", raw } and matches nothing, so a mistake empties the results rather than
silently widening them. It stays in links as typed. QueryBar underlines it and says why in the
dropdown, without adding anything to the page's layout. A backend that translates queries must
treat an invalid token as matching nothing too.
Recent searches
import { useSearchHistory } from "@ox0/hooks"
const history = useSearchHistory("orders") // one key per box; needs a StorageProvider
<QueryBar history={history} … />↓ in the empty box lists recent searches (the newest 10, then a "Show all" row) above the field
list; typing narrows them, matching the whole history. Focusing or clicking the box doesn't open
the dropdown. A query is recorded when it is applied (in live mode, when focus leaves the box),
deduplicated, newest first, 15 at most (useSearchHistory(key, { limit }) keeps more). Shift+Delete or the row's × removes one. Searches can name real people: pass
{ storage: "session" } to forget them when the tab closes.
Saved searches
import { useSavedSearches } from "@ox0/hooks"
const saved = useSavedSearches("orders") // same key as the box's history
<QueryBar history={history} saved={saved} … />A star in the bar saves the applied query and opens a small popover to name it (optional); on a
saved query it renames or removes it. Saved searches list above recent ones, name first and query
after it in lighter type, 10 at a time with a "Show all" row. Typing plain words (no field:value
yet) filters them by name or query and hides recent ones. A recent search is saved from its row's
star or ⌘/Ctrl+S; a saved one is renamed from its pencil and removed with its × or Shift+Delete,
with an Undo toast when the app has a ToastProvider.
Filtering records in the browser
import { createMatcher, defineQuerySchema } from "@ox0/query"
// With <Payment>, a field without `get` must be a property of Payment: a typo is a type error.
const payments = defineQuerySchema<Payment>({
fields: [
{ id: "payer", type: "text" },
{ id: "amount", type: "numeric" },
// A list: a filter matches if any patient does; -patients:… if none do.
{ id: "patients", type: "text", get: (p) => p.patients.map((patient) => patient.name) },
// Show and type "bill" for a value stored as "statement".
{
id: "kind",
type: "enum",
values: ["bill", "receipt"],
get: (p) => (p.kind === "statement" ? "bill" : p.kind),
},
],
})
const visible = rows.filter(createMatcher(payments, query, { timeZone: "America/New_York" }))An empty list, null, or "" is no value: it matches no filter, positive or negated, and
field:-* finds it. matchesPattern(value, pattern) checks a single * pattern.
Help
QueryBar's dropdown footer has a "Search syntax" button (also ⌘/Ctrl+/) that opens the guide,
with examples built from your schema's fields; clicking one applies it. <QueryHelp schema={…} onExample={…} /> renders the same
guide anywhere else.
