UNPKG

@adonis-agora/filter

Version:

Server-side query filtering/sorting/pagination for AdonisJS — Spatie-style input, a Lucid adapter, and field allow-listing. Part of the Agora ecosystem.

231 lines 12.5 kB
import { type LucidModelLike } from './aggregate.js'; import type { FieldAliases } from './field_aliases.js'; import type { FilterFieldTypeInfo } from './generate_client.js'; import type { ColumnFilter } from './operators.js'; import type { ComputedFields, FilterConfig, FilterFieldKind, FullTextSearchConfig, SortItem, VectorSimilarityConfig } from './types.js'; /** * Thrown when a {@link defineFilter} declaration is itself invalid (a developer * error surfaced at wiring time, not a bad client request). Request-time * violations (a disallowed field under `throwOnInvalid`) still surface as * `InvalidColumnFilterError` from the runner — this is the one new typed error, * for the declaration boundary the NestJS `@Filterable` decorator guarded. */ export declare class FilterDefinitionError extends Error { constructor(message: string); } /** Column-level allow-list for a relation: `'*'` (any column) or a bare-name list. */ export type RelationColumns = string[] | '*'; /** * The colocated allow-list form: field name → its column kind, declaring both at once. * * The array form makes every non-string field appear twice (once in `filterable`, once in * `fieldTypes`); this states each field and its type in one place, and desugars to exactly that * pair. Fields whose kind carries no contract can still use `'string'` — it is the no-op kind. * * ```ts * defineFilter({ filterable: { advisorId: 'string', dayOfWeek: 'number', isRecurring: 'boolean' } }) * ``` */ export type FilterableMap = Record<string, FilterFieldKind>; /** `filterable` as accepted on input: the classic list/`'*'`, or the colocated map. */ export type FilterableInput = RelationColumns | FilterableMap; /** * The filterable/sortable declaration for one whitelisted relation, keyed by the * relation name on the owning model. Column names are **bare** (unprefixed) — the * request field is the dotted path `relation.column`. Nesting `relations` again * whitelists deeper paths (`relation.child.column`), bounded by `maxDepth`. */ export interface RelationSpec { /** Columns of the related model clients may filter on. Defaults to `'*'`. */ filterable?: RelationColumns; /** Columns of the related model clients may sort on. Defaults to `filterable`. */ sortable?: RelationColumns; /** Further whitelisted relations reachable from this one (one hop deeper). */ relations?: Record<string, RelationSpec>; /** * Numeric child columns exposed to the to-many aggregate functions * (`$sum`/`$avg`/`$min`/`$max`) — e.g. `['views', 'total']` makes * `posts.$sum.views` and `posts.$max.total` filterable/sortable. `$count` * needs no column and is synthesised for every to-many relation regardless. * * Because Lucid does not reflect a column's SQL type, listing a column here is * how the developer asserts it is numeric (the aggregate allow-list). Only * meaningful when the owning {@link DefineFilterOptions.model} is set so the * relation's FK/pivot metadata can be discovered; ignored otherwise. */ aggregates?: string[]; } /** * Reads the current tenant id from the request context (the AdonisJS * `HttpContext`, or any object the caller passes). A `null`/`undefined` result * means "no tenant in context" — tenant scoping is then skipped (opt-in, exactly * like the NestJS `@TenantScoped` no-op when no tenant resolves). */ export type TenantResolver = (ctx: unknown) => string | number | null | undefined; /** Tenant auto-scope: constrain `column` to the tenant id resolved from ctx. */ export interface TenantScopeSpec { /** The model column constrained to the current tenant id (e.g. `'tenantId'`). */ column: string; /** Resolves the tenant id from the request ctx. Nullish → scope is skipped. */ resolve: TenantResolver; } /** * The declarative filter definition — the idiomatic AdonisJS reimplementation of * everything the NestJS `@Filterable`/`@Relations`/`@TenantScoped` decorators * encoded, as a plain options object (no decorators, no metadata reflection). */ export interface DefineFilterOptions { /** * Columns clients may filter on. Three forms: * - `['a', 'b']` — a bare allow-list. * - `'*'` — any base column (use with care). * - `{ a: 'string', b: 'number' }` — the colocated form: allow-list AND {@link * DefineFilterOptions.fieldTypes} in one place, so a non-string field is not written twice. */ filterable: FilterableInput; /** Columns clients may sort on. Defaults to {@link DefineFilterOptions.filterable}. */ sortable?: RelationColumns; /** * Columns the free-text `search` term scans with a portable ILIKE — the * default search path when {@link DefineFilterOptions.fullText} is not set. */ searchable?: string[]; /** * Per-field column value types. Declaring a field here does two things at once: * * 1. **Server-side validation.** A query-string filter value is always a string, and Postgres * silently casts the benign cases (`day_of_week = '3'` works) — so the gap stays invisible * until a client sends something uncastable (`is_recurring = 'xyz'`), which raises * `invalid input syntax for type boolean` at the database and surfaces as a **500 driven by * user input**. With a declared kind the value is coerced up front, and one that can't be * coerced is treated exactly like a disallowed field: dropped, or a loud * `InvalidColumnFilterError` (→ 400) under {@link DefineFilterOptions.throwOnInvalid}. * 2. **Type-aware client codegen.** `make:filter-client` reads the same declaration, so the * emitted client narrows operators per field instead of being operator-permissive. * * One declaration, both ends. Undeclared fields keep the previous behaviour (no coercion), so * adding this to an existing spec is opt-in and backwards compatible. */ fieldTypes?: Record<string, FilterFieldTypeInfo>; /** * Opt-in Postgres tsvector full-text search. When set, the request `search` * string routes through `websearch_to_tsquery`/`@@` (and optional `ts_rank`) * instead of the ILIKE `searchable` scan. Column(s) + language + rank. */ fullText?: FullTextSearchConfig; /** Whitelisted relations and their nested filterable/sortable columns. */ relations?: Record<string, RelationSpec>; /** * The owning Lucid model — enables to-many aggregate fields * (`$count`/`$sum`/`$avg`/`$min`/`$max`). Its relation metadata (hasMany / * manyToMany FK + pivot columns) is introspected at build time to synthesise * aggregate computed sources for the whitelisted {@link relations}. Optional; * without it, aggregate fields are simply not available (the feature degrades * gracefully) — every other feature works with or without it. Also supplies * the default {@link table} (the model's table name) used as the correlated * subquery's outer alias. */ model?: LucidModelLike; /** * Maximum relation-path depth (number of relation hops; a base column is * depth 0, `posts.title` is depth 1, `posts.comments.body` is depth 2). * Defaults to the deepest declared relation nesting. An explicit smaller value * caps paths even when a deeper relation is declared. */ maxDepth?: number; /** Client-alias → resolved-target field remapping (see {@link resolveFieldAlias}). */ aliases?: FieldAliases; /** * Opt-in pgvector embedding-similarity ordering (distinct from text `search`): * declares the vector column (and metric / threshold / top-K) rows are ranked * by when a request carries a query embedding. Additive — a spec without this * is unchanged. */ vectorSimilarity?: VectorSimilarityConfig; /** * Virtual/computed fields — alias → dev-declared SQL expression * ({@link ComputedFields}). Two source forms: a verbatim string * (`{ fullName: "first || ' ' || last" }`) or a function * (`{ postCount: ({ alias }) => \`(SELECT COUNT(*) FROM posts WHERE posts.author_id = ${alias}.id)\` }`) * for correlated subqueries. A declared alias becomes filterable and sortable * as if it were a real column; the client value stays parameterized and only * the dev expression is inlined. Function-form sources need {@link table}. */ computed?: ComputedFields; /** * The root model's table name, surfaced to computed-field functions as the * outer alias (Lucid gives the main table no generated alias, so the table * name IS the alias). Required for function-form computed fields and to-many * aggregate fields whose correlated subqueries reference the outer row. */ table?: string; /** Opt-in tenant auto-scope read from ctx. */ tenant?: TenantScopeSpec; /** * Server-declared filters always applied (in real column terms, AND-combined * with the request filters). Not subject to the allow-list — they are trusted * server policy, never client input. */ defaultFilters?: ColumnFilter[]; /** Sort applied when the request supplies none (stable default ordering). */ defaultSort?: SortItem[]; /** Default page size when the request gives none. Default 25 (in the runner). */ defaultSize?: number; /** Hard cap on page size. Default 100 (in the runner). */ maxSize?: number; /** Throw `InvalidColumnFilterError` on a disallowed field instead of dropping it. */ throwOnInvalid?: boolean; } /** * A resolved, reusable, frozen filter definition produced by {@link defineFilter}. * Build it once (module scope) and hand it to {@link applyFilterFromRequest} on * every request. The `isFilterable`/`isSortable` predicates are the allow-list * boundary — relation-path and depth aware — that the runner enforces. */ export interface FilterSpec { readonly filterable: RelationColumns; readonly sortable: RelationColumns; readonly searchable: readonly string[]; /** Declared column value kinds — drives value coercion AND client codegen. */ readonly fieldTypes: Readonly<Record<string, FilterFieldTypeInfo>> | undefined; readonly fullText: FullTextSearchConfig | undefined; readonly relations: Readonly<Record<string, RelationSpec>>; readonly maxDepth: number; readonly aliases: FieldAliases | undefined; readonly vectorSimilarity: VectorSimilarityConfig | undefined; /** Declared computed/virtual fields (alias → SQL source). */ readonly computed: ComputedFields | undefined; /** Root table name surfaced to computed-field functions as the outer alias. */ readonly table: string | undefined; readonly tenant: TenantScopeSpec | undefined; readonly defaultFilters: readonly ColumnFilter[]; readonly defaultSort: readonly SortItem[]; readonly defaultSize: number | undefined; readonly maxSize: number | undefined; readonly throwOnInvalid: boolean; /** Is this (possibly relation-dotted, alias-resolved) field filterable? */ isFilterable(field: string): boolean; /** Is this (possibly relation-dotted, alias-resolved) field sortable? */ isSortable(field: string): boolean; } /** * Build a reusable {@link FilterSpec} from a declarative {@link DefineFilterOptions}. * * This is the AdonisJS-idiomatic replacement for the NestJS decorator stack: * instead of `@Filterable`/`@Relations`/`@TenantScoped` metadata read by an * interceptor, the definition is an explicit, framework-free config object built * once and passed explicitly to {@link applyFilterFromRequest}. It captures the * same feature set the decorators encoded — filterable/sortable allow-listing, * a relation whitelist with a depth cap, field aliases, tenant scoping, and * default filters/sort. */ export declare function defineFilter(options: DefineFilterOptions): FilterSpec; /** * Project a {@link FilterSpec} onto the per-call {@link FilterConfig} the runner's * {@link applyFilter}/{@link applyCursor} consume. The allow-lists become * predicates (so relation-path + depth rules survive), and `defaultSort` fields * are unioned into the sortable predicate so a server-declared default ordering * is never dropped by the client-facing sort allow-list. */ export declare function specToFilterConfig(spec: FilterSpec): FilterConfig; //# sourceMappingURL=filter_spec.d.ts.map