@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.
303 lines • 16.3 kB
TypeScript
import type { ColumnFilter } from './operators.js';
import type { ComputedSource, GroupByCountOptions, SortItem } from './types.js';
/**
* The structural subset of a Lucid `ModelQueryBuilder` / `DatabaseQueryBuilder`
* the adapter drives. An `@adonisjs/lucid` query builder satisfies it — declared
* locally so the adapter never hard-imports Lucid (and stays unit-testable with a
* recording mock). The nested-callback `where`/`orWhere` overloads model Lucid's
* grouping closures used for AND/OR composition.
*/
export interface QueryBuilderLike {
where(callback: (qb: QueryBuilderLike) => void): QueryBuilderLike;
where(column: string, value: unknown): QueryBuilderLike;
where(column: string, operator: string, value: unknown): QueryBuilderLike;
orWhere(callback: (qb: QueryBuilderLike) => void): QueryBuilderLike;
/**
* Constrain the query to rows whose `relation` has at least one related row
* matching the nested conditions — Lucid's `whereHas`. Used to translate a
* dotted relation-path filter (`posts.title = x`) into a real subquery
* (`whereHas('posts', (q) => q.where('title', x))`) instead of a dotted
* column reference. The recording mock implements it so relation translation
* stays unit-testable.
*
* `relation` is `any`, not `string`, and that is load-bearing: Lucid types its
* own `whereHas` as `<Name extends ExtractModelRelations<Model>>(relation: Name, ...)`
* — a union of the model's *literal* relation names. `string` is not assignable
* to that union, so declaring `relation: string` here makes every real Lucid
* builder fail to satisfy this interface, and TS reports the failure against
* `where` (the first member it tries), which sends you hunting in the wrong
* place. Widening the member to optional does NOT help: an optional member
* that is present is still checked. Since the parameter must accept the
* `string` the adapter passes AND be assignable to each model's relation-name
* union, `any` is the only type that works.
*/
whereHas(relation: any, callback: (qb: QueryBuilderLike) => void): QueryBuilderLike;
whereNot(column: string, value: unknown): QueryBuilderLike;
whereIn(column: string, values: unknown[]): QueryBuilderLike;
whereNotIn(column: string, values: unknown[]): QueryBuilderLike;
whereNull(column: string): QueryBuilderLike;
whereNotNull(column: string): QueryBuilderLike;
whereBetween(column: string, range: [unknown, unknown]): QueryBuilderLike;
whereNotBetween(column: string, range: [unknown, unknown]): QueryBuilderLike;
whereILike(column: string, value: string): QueryBuilderLike;
orWhereILike(column: string, value: string): QueryBuilderLike;
orderBy(column: string, direction: 'asc' | 'desc'): QueryBuilderLike;
/**
* Project columns — Lucid's `select`. Optional on the seam: only aggregations need it
* (entity-row listings select whole rows), so a minimal custom implementation can omit it
* until it serves a `groupByCount`.
*/
select?: (...columns: string[]) => unknown;
/**
* Aggregate rows — Lucid's `count`. Optional, same reason as {@link select}: only the
* group-by-count aggregation calls it, with a `'* AS count'`-style argument.
*/
count?: (column: string) => unknown;
/**
* Group rows — Lucid's `groupBy`. Optional, same reason: only the group-by-count aggregation
* calls it.
*/
groupBy?: (...columns: string[]) => unknown;
/**
* Skip rows — Lucid's `offset`. Optional: entity-row pagination goes through Lucid's own
* `paginate`, so only the group-by-count aggregation pages with limit/offset directly.
*/
offset?: (n: number) => unknown;
/**
* Add a raw SQL predicate with positional bindings — Lucid's `whereRaw`. The
* escape hatch for constraints no structured method can express, used here for
* pgvector similarity (`embedding <=> ?::vector < ?`). `sql` is server-authored
* (never client text); every user value travels as a `?` binding. Optional on
* real Lucid builders; the recording mock implements it so vector translation
* stays unit-testable and the package stays framework-free.
*/
whereRaw(sql: string, bindings?: readonly unknown[]): QueryBuilderLike;
/**
* Add a raw SQL ordering with positional bindings — Lucid's `orderByRaw`. Used
* to order by a pgvector distance expression (`embedding <=> ?::vector asc`),
* which no column-name `orderBy` can express. Same seam contract as
* {@link QueryBuilderLike.whereRaw}.
*/
orderByRaw(sql: string, bindings?: readonly unknown[]): QueryBuilderLike;
/**
* Restrict the query to DISTINCT tuples of `columns` — Lucid's `distinct`.
* Used by {@link applyDistinct} to execute a client `.distinct(...)`
* projection. The columns are the already alias-resolved, allow-listed field
* names; a variadic column list matches Lucid's signature exactly. Optional on
* real Lucid builders; the recording mock implements it so the translation
* stays unit-testable.
*/
distinct(...columns: string[]): QueryBuilderLike;
limit(count: number): QueryBuilderLike;
}
/**
* A pgvector distance metric → the operator applied between the vector column and
* the query embedding:
*
* - `'cosine'` — cosine distance, pgvector `<=>` (default; the usual choice for
* normalized embeddings).
* - `'l2'` — Euclidean / L2 distance, pgvector `<->`.
* - `'innerProduct'` — negative inner product, pgvector `<#>`.
*
* All three are *distance* operators (smaller = more similar), so nearest-first
* ranking is always ascending order.
*/
export type VectorDistanceMetric = 'cosine' | 'l2' | 'innerProduct';
/**
* Options for {@link applyVectorSimilarity} — an **embedding similarity** ordering
* (pgvector) against a `vector` column. This is DISTINCT from full-text search
* ({@link applyFullTextSearch}): here a query *embedding* is ranked against the
* column by a distance metric, optionally filtered by a distance threshold and
* truncated to the top-K nearest rows. (Full-text search matches a text *query
* string* against a tsvector document.)
*/
export interface VectorSimilarityOptions {
/** The pgvector column to compare the query embedding against (e.g. `'embedding'`). */
column: string;
/** The query embedding — the vector rows are ranked by similarity to. */
vector: readonly number[];
/** Distance metric → pgvector operator. Default `'cosine'` (`<=>`). */
metric?: VectorDistanceMetric;
/**
* Keep only rows whose distance to the query embedding is strictly below this
* value (a `WHERE embedding <=> ? < threshold`). Omitted → no distance filter.
*/
threshold?: number;
/**
* Limit the result to the K nearest rows (a `LIMIT`). Omitted → no limit is
* added here (the caller's pagination still applies).
*/
topK?: number;
/**
* Order by ascending distance (nearest first). Default `true`. Set `false` to
* apply only a threshold filter without changing the query's ordering.
*/
order?: boolean;
}
/**
* Apply an **embedding similarity** ordering (pgvector) to a Lucid query builder.
*
* This is *not* full-text search — it ranks rows by distance between a stored
* embedding column and a query *embedding vector*. For matching a text query
* string, use {@link applyFullTextSearch} instead.
*
* pgvector expresses similarity through raw distance operators (`<=>`, `<->`,
* `<#>`) that no structured query-builder method covers, so this drives the
* adapter's raw seam ({@link QueryBuilderLike.whereRaw}/`orderByRaw`) with the
* query embedding passed as a positional binding — the column name is the only
* interpolated fragment, and it is validated against a strict identifier charset
* so it can never carry injection. The distance expression is
* `<column> <op> ?::vector`, cast to `vector` so a text binding compares against
* the column.
*
* A no-op when the embedding is empty. When `order` is not `false`, rows are
* ordered nearest-first (ascending distance); `threshold` adds a max-distance
* filter; `topK` truncates to the K nearest.
*/
export declare function applyVectorSimilarity(qb: QueryBuilderLike, opts: VectorSimilarityOptions): void;
/**
* Options for {@link applyFullTextSearch} — a Postgres **tsvector full-text
* search** (the parity behavior of the NestJS reference's `applyVectorSearch`).
* The user query string is matched against a text-search document with
* `websearch_to_tsquery` and the `@@` operator, optionally ranked by `ts_rank`.
*
* This is DISTINCT from {@link VectorSimilarityOptions} (embedding similarity):
* here the input is arbitrary *text*, tokenized by a language config; there the
* input is a numeric *embedding vector*.
*/
export interface FullTextSearchOptions {
/**
* The user's raw search text. Always passed as a positional binding to
* `websearch_to_tsquery` — never interpolated into SQL. A no-op when blank.
*/
query: string;
/**
* The document to match against. Either a single precomputed `tsvector`
* column (matched directly with `@@`), or one-or-more plain text columns that
* are wrapped in `to_tsvector(<language>, ...)` at query time (see
* {@link FullTextSearchOptions.columnKind}). Passing multiple columns implies
* `'text'`.
*/
column: string | readonly string[];
/**
* Postgres text-search config / language used for both `websearch_to_tsquery`
* and any `to_tsvector` wrapping (e.g. `'english'`, `'simple'`). Default
* `'english'`. Validated against the strict identifier charset before it is
* spliced into SQL.
*/
language?: string;
/**
* Add relevance ordering for matched rows (`ORDER BY ts_rank(...) DESC`). Off
* by default because it changes the query's default ordering; opt in for
* best-match-first results.
*/
rank?: boolean;
/**
* How {@link FullTextSearchOptions.column} is treated:
* - `'tsvector'` — a precomputed `tsvector` column, matched directly (`col @@ ...`);
* - `'text'` — plain text column(s), wrapped in `to_tsvector(<language>, col ...)`.
*
* Defaults to `'tsvector'` for a single column and `'text'` when multiple
* columns are given.
*/
columnKind?: 'tsvector' | 'text';
}
/**
* Apply a Postgres **tsvector full-text search** to a Lucid query builder — the
* parity port of the NestJS reference's `applyVectorSearch` (tsvector), NOT the
* embedding similarity {@link applyVectorSimilarity}.
*
* Injection-safety: the user `query` string ALWAYS travels as a positional
* binding (`websearch_to_tsquery('<lang>', ?)`); it is never interpolated.
* `websearch_to_tsquery` (not the raw `to_tsquery`) parses arbitrary user input
* — multi-word text, `"quoted phrases"`, `-exclude`, `or` — without throwing a
* syntax error. The only spliced fragments are the column name(s) and the
* language config, each validated against the strict identifier charset
* ({@link SAFE_IDENTIFIER}) before use, so neither can carry injection.
*
* The match predicate is `<document> @@ websearch_to_tsquery('<lang>', ?)` where
* `<document>` is either the tsvector column directly, or
* `to_tsvector('<lang>', coalesce(col1,'') || ' ' || coalesce(col2,''))` for
* text columns. When `rank` is set, rows are additionally ordered by descending
* `ts_rank(<document>, websearch_to_tsquery('<lang>', ?))`. A no-op for a blank
* query.
*/
export declare function applyFullTextSearch(qb: QueryBuilderLike, opts: FullTextSearchOptions): void;
/** Apply an array of column filters (combined with AND) to a Lucid query builder. */
export declare function applyColumnFilters(qb: QueryBuilderLike, filters: ColumnFilter[]): void;
/** Apply sort directives to a Lucid query builder, in order. */
export declare function applySort(qb: QueryBuilderLike, sorts: SortItem[]): void;
/**
* Apply a DISTINCT projection over `columns` to a Lucid query builder — the
* executable half of the client's `.distinct(...)` (which until now the server
* silently ignored). A no-op for an empty list. The columns are the
* already-validated, alias-resolved field names (the runner resolves aliases and
* enforces the allow-list before calling this), so nothing client-controlled is
* interpolated: Lucid quotes each identifier itself.
*/
export declare function applyDistinct(qb: QueryBuilderLike, columns: string[]): void;
/**
* Terminal group-by-count aggregation over one column:
* `SELECT <col> AS value, COUNT(*) AS count … GROUP BY <col>`, most groups first —
* what populates a filter dropdown. `column` must already be validated (allow-listed): it is
* interpolated as an identifier, while every client VALUE rides a positional binding.
*
* Fixed ordering (count desc, value asc) is load-bearing, not cosmetic: the answer is pageable,
* and paging an unordered listing repeats and skips rows.
*
* Requires a builder carrying the optional aggregation seam (`select`/`count`/`groupBy`/`offset`);
* throws a plain `Error` naming the missing method otherwise, so a minimal custom implementation
* fails loudly instead of silently returning entity rows.
*/
export declare function applyGroupByCount(qb: QueryBuilderLike, column: string, opts?: GroupByCountOptions): void;
/**
* Resolve a {@link ComputedSource} to its final SQL expression string. The
* string form is verbatim; the function form is invoked with the root table
* {@link ComputedContext} so it can splice the outer alias into a correlated
* subquery. Either way the result is dev-authored — never client text.
*/
export declare function resolveComputedExpression(source: ComputedSource, alias: string): string;
/**
* Apply a filter on a dev-declared **computed field** to a Lucid query builder.
*
* The already-resolved `expression` is inlined as the raw left-hand side (it is
* dev-authored — a verbatim string or a function's output — never client text);
* the client's filter VALUE always rides through as a positional `?` binding,
* exactly the injection-safety contract real-column filters have. The whole
* expression is parenthesized so a compound source
* (`first || ' ' || last`, `(SELECT …)`) composes under the operator without a
* precedence surprise.
*/
export declare function applyComputedField(qb: QueryBuilderLike, expression: string, filter: ColumnFilter): void;
/**
* Append an ORDER BY on a dev-declared **computed field** to a Lucid query
* builder via `orderByRaw`. Uses append semantics (like `orderBy`) so a computed
* sort composes with real-column sorts in request order. The expression is
* dev-authored and parenthesized; the direction is a validated literal.
*/
export declare function applyComputedSort(qb: QueryBuilderLike, expression: string, direction: 'asc' | 'desc'): void;
/**
* Apply a keyset (cursor) seek predicate to a Lucid query builder for row-value
* comparison across the keyset columns.
*
* Given a keyset (the active sort columns plus a primary-key tiebreaker, each
* with a direction) and a boundary row's values, this constrains the query to
* rows strictly *after* the boundary in keyset order. The row-value comparison
* is expanded into the portable "OR of AND tiers" form that works across all
* SQL dialects:
*
* ```text
* (c0 OP0 v0)
* OR (c0 = v0 AND c1 OP1 v1)
* OR (c0 = v0 AND c1 = v1 AND c2 OP2 v2) ...
* ```
*
* where `OPi` is `>` for an `asc` column and `<` for a `desc` column. The whole
* predicate is wrapped in one AND-group so it composes with any existing
* `where` conditions. A no-op when the keyset is empty or `values` does not line
* up positionally with it.
*/
export declare function applyKeyset(qb: QueryBuilderLike, keyset: SortItem[], values: unknown[]): void;
/** Apply a free-text ILIKE search across `columns` (OR-combined) to a Lucid query builder. */
export declare function applySearch(qb: QueryBuilderLike, term: string, columns: string[]): void;
//# sourceMappingURL=lucid_adapter.d.ts.map