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.

303 lines 16.3 kB
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