@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.
457 lines • 18.2 kB
JavaScript
import { escapeLike } from './escape-like.js';
import { normalizeOperator } from './validate-column-filter.js';
/** pgvector distance operator for each {@link VectorDistanceMetric}. */
const VECTOR_OPERATORS = {
cosine: '<=>',
l2: '<->',
innerProduct: '<#>',
};
/** A safe SQL identifier: an unquoted column, optionally `table.column` qualified. */
const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/;
/** Quote a (possibly dotted) identifier for Postgres, one segment at a time. */
function quoteColumn(column) {
return column
.split('.')
.map((seg) => `"${seg}"`)
.join('.');
}
/**
* Serialize a numeric embedding to a pgvector literal (`[1,2,3]`). Returns `null`
* for an empty vector (a no-op signal). Throws on a non-finite component so a bad
* embedding fails loudly rather than emitting `NaN`/`Infinity` into SQL.
*/
function serializeVector(vector) {
if (vector.length === 0)
return null;
for (const n of vector) {
if (typeof n !== 'number' || !Number.isFinite(n)) {
throw new TypeError('Vector search embedding must contain only finite numbers.');
}
}
return `[${vector.join(',')}]`;
}
/**
* 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 function applyVectorSimilarity(qb, opts) {
const literal = serializeVector(opts.vector);
if (literal === null)
return;
if (!SAFE_IDENTIFIER.test(opts.column)) {
throw new TypeError(`Invalid vector column name: ${JSON.stringify(opts.column)}`);
}
const operator = VECTOR_OPERATORS[opts.metric ?? 'cosine'];
const distance = `${quoteColumn(opts.column)} ${operator} ?::vector`;
if (opts.threshold !== undefined) {
qb.whereRaw(`${distance} < ?`, [literal, opts.threshold]);
}
if (opts.order !== false) {
qb.orderByRaw(`${distance} asc`, [literal]);
}
if (opts.topK !== undefined) {
qb.limit(opts.topK);
}
}
/**
* 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 function applyFullTextSearch(qb, opts) {
const query = opts.query.trim();
if (query.length === 0)
return;
const columns = Array.isArray(opts.column) ? opts.column : [opts.column];
if (columns.length === 0)
return;
for (const col of columns) {
if (!SAFE_IDENTIFIER.test(col)) {
throw new TypeError(`Invalid full-text search column name: ${JSON.stringify(col)}`);
}
}
const language = opts.language ?? 'english';
if (!SAFE_IDENTIFIER.test(language)) {
throw new TypeError(`Invalid full-text search language: ${JSON.stringify(language)}`);
}
// The user query is a positional binding; only the validated language splices in.
const tsquery = `websearch_to_tsquery('${language}', ?)`;
// A precomputed tsvector column is matched directly; text columns are tokenized
// at query time via to_tsvector, coalescing NULLs so a null column can't null
// the whole document.
const kind = opts.columnKind ?? (columns.length > 1 ? 'text' : 'tsvector');
const document = kind === 'text'
? `to_tsvector('${language}', ${columns
.map((c) => `coalesce(${quoteColumn(c)}, '')`)
.join(" || ' ' || ")})`
: quoteColumn(columns[0]);
qb.whereRaw(`${document} @@ ${tsquery}`, [query]);
if (opts.rank) {
qb.orderByRaw(`ts_rank(${document}, ${tsquery}) desc`, [query]);
}
}
/** Wrap a value as a LIKE pattern with escaped metacharacters. */
function like(value, kind) {
const v = escapeLike(String(value));
if (kind === 'startsWith')
return `${v}%`;
if (kind === 'endsWith')
return `%${v}`;
return `%${v}%`;
}
/**
* Apply a single (leaf) column filter to the builder.
*
* A dotted `field` is a **relation path** (`posts.title`, `posts.comments.body`):
* every segment but the last is a relation hop, translated into a nested Lucid
* `whereHas` subquery, and the final segment is the bare column the operator
* lands on inside the innermost relation query — so `posts.title = x` becomes
* `whereHas('posts', (q) => q.where('title', x))`. Depth is already bounded by
* the spec's `maxDepth` allow-list before a filter reaches the adapter, so this
* only translates paths that were explicitly whitelisted. A non-dotted `field`
* is a plain base column and takes the operator switch directly.
*/
function applyLeaf(qb, field, operator, value) {
const dot = field.indexOf('.');
if (dot !== -1) {
const relation = field.slice(0, dot);
const rest = field.slice(dot + 1);
qb.whereHas(relation, (sub) => applyLeaf(sub, rest, operator, value));
return;
}
switch (operator) {
case 'equals':
qb.where(field, value);
break;
case 'notEquals':
qb.whereNot(field, value);
break;
case 'contains':
case 'iContains':
qb.whereILike(field, like(value, 'contains'));
break;
case 'startsWith':
qb.whereILike(field, like(value, 'startsWith'));
break;
case 'endsWith':
qb.whereILike(field, like(value, 'endsWith'));
break;
case 'notContains':
qb.where((sub) => sub.whereNot(field, value).whereNotNull(field));
break;
case 'gt':
qb.where(field, '>', value);
break;
case 'gte':
qb.where(field, '>=', value);
break;
case 'lt':
qb.where(field, '<', value);
break;
case 'lte':
qb.where(field, '<=', value);
break;
case 'between':
qb.whereBetween(field, value);
break;
case 'notBetween':
qb.whereNotBetween(field, value);
break;
case 'in':
case 'isAnyOf':
qb.whereIn(field, value);
break;
case 'notIn':
qb.whereNotIn(field, value);
break;
case 'isNull':
case 'notExists':
qb.whereNull(field);
break;
case 'isNotNull':
case 'exists':
qb.whereNotNull(field);
break;
case 'isEmpty':
qb.where(field, '');
break;
case 'isNotEmpty':
qb.whereNot(field, '');
break;
}
}
/**
* Apply one {@link ColumnFilter} (possibly an AND/OR group) to a builder. A leaf
* is a single condition; `AND`/`OR` recurse into a grouped sub-builder so the
* boolean structure maps to Lucid's nested `where`/`orWhere` closures.
*/
function applyOne(qb, filter) {
const op = normalizeOperator(filter.operator);
const hasField = typeof filter.field === 'string' && filter.field.length > 0;
qb.where((group) => {
if (hasField) {
applyLeaf(group, filter.field, op, filter.value);
}
if (filter.AND) {
for (const sub of filter.AND) {
group.where((g) => applyOne(g, sub));
}
}
if (filter.OR) {
for (const sub of filter.OR) {
group.orWhere((g) => applyOne(g, sub));
}
}
});
}
/** Apply an array of column filters (combined with AND) to a Lucid query builder. */
export function applyColumnFilters(qb, filters) {
for (const filter of filters) {
applyOne(qb, filter);
}
}
/** Apply sort directives to a Lucid query builder, in order. */
export function applySort(qb, sorts) {
for (const sort of sorts) {
qb.orderBy(sort.field, sort.direction);
}
}
/**
* 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 function applyDistinct(qb, columns) {
if (columns.length === 0)
return;
qb.distinct(...columns);
}
/**
* 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 function applyGroupByCount(qb, column, opts = {}) {
if (!qb.select || !qb.count || !qb.groupBy || !qb.offset) {
throw new Error('groupByCount needs a builder with select/count/groupBy/offset — the active one does not implement the aggregation seam.');
}
qb.select(`${column} AS value`);
qb.count('* AS count');
const needle = opts.search?.trim();
if (needle) {
qb.whereRaw(`LOWER(??) LIKE ?`, [column, `%${escapeLike(needle.toLowerCase())}%`]);
}
qb.groupBy(column);
qb.orderBy('count', 'desc');
qb.orderBy(column, 'asc');
if (opts.limit !== undefined)
qb.limit(Math.max(0, opts.limit));
if (opts.offset !== undefined)
qb.offset(Math.max(0, opts.offset));
}
/**
* 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 function resolveComputedExpression(source, alias) {
return typeof source === 'function' ? source({ alias }) : source;
}
/**
* 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 function applyComputedField(qb, expression, filter) {
const op = normalizeOperator(filter.operator);
const value = filter.value;
const lhs = `(${expression})`;
switch (op) {
case 'equals':
qb.whereRaw(`${lhs} = ?`, [value]);
break;
case 'notEquals':
qb.whereRaw(`${lhs} <> ?`, [value]);
break;
case 'gt':
qb.whereRaw(`${lhs} > ?`, [value]);
break;
case 'gte':
qb.whereRaw(`${lhs} >= ?`, [value]);
break;
case 'lt':
qb.whereRaw(`${lhs} < ?`, [value]);
break;
case 'lte':
qb.whereRaw(`${lhs} <= ?`, [value]);
break;
case 'in':
case 'isAnyOf': {
const values = Array.isArray(value) ? value : [];
// An empty IN list matches nothing — emit an always-false predicate rather
// than invalid `IN ()` SQL.
if (values.length === 0)
qb.whereRaw('1 = 0');
else
qb.whereRaw(`${lhs} in (${values.map(() => '?').join(', ')})`, values);
break;
}
case 'notIn': {
const values = Array.isArray(value) ? value : [];
if (values.length === 0)
qb.whereRaw('1 = 1');
else
qb.whereRaw(`${lhs} not in (${values.map(() => '?').join(', ')})`, values);
break;
}
case 'between': {
const [low, high] = value;
qb.whereRaw(`${lhs} between ? and ?`, [low, high]);
break;
}
case 'notBetween': {
const [low, high] = value;
qb.whereRaw(`${lhs} not between ? and ?`, [low, high]);
break;
}
case 'contains':
case 'iContains':
qb.whereRaw(`${lhs} ilike ?`, [like(value, 'contains')]);
break;
case 'notContains':
qb.whereRaw(`(${lhs} not ilike ? or ${lhs} is null)`, [like(value, 'contains')]);
break;
case 'startsWith':
qb.whereRaw(`${lhs} ilike ?`, [like(value, 'startsWith')]);
break;
case 'endsWith':
qb.whereRaw(`${lhs} ilike ?`, [like(value, 'endsWith')]);
break;
case 'isNull':
case 'notExists':
qb.whereRaw(`${lhs} is null`);
break;
case 'isNotNull':
case 'exists':
qb.whereRaw(`${lhs} is not null`);
break;
case 'isEmpty':
qb.whereRaw(`${lhs} = ?`, ['']);
break;
case 'isNotEmpty':
qb.whereRaw(`${lhs} <> ?`, ['']);
break;
}
}
/**
* 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 function applyComputedSort(qb, expression, direction) {
qb.orderByRaw(`(${expression}) ${direction === 'desc' ? 'desc' : 'asc'}`);
}
/**
* 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 function applyKeyset(qb, keyset, values) {
if (keyset.length === 0 || values.length !== keyset.length)
return;
qb.where((outer) => {
for (let tier = 0; tier < keyset.length; tier++) {
const buildTier = (inner) => {
// Equality on every column before this tier.
for (let i = 0; i < tier; i++) {
inner.where(keyset[i].field, values[i]);
}
const cmp = keyset[tier].direction === 'asc' ? '>' : '<';
inner.where(keyset[tier].field, cmp, values[tier]);
};
// First tier seeds the group with AND; the rest OR onto it.
if (tier === 0)
outer.where(buildTier);
else
outer.orWhere(buildTier);
}
});
}
/** Apply a free-text ILIKE search across `columns` (OR-combined) to a Lucid query builder. */
export function applySearch(qb, term, columns) {
if (columns.length === 0 || term.length === 0)
return;
const pattern = like(term, 'contains');
qb.where((group) => {
for (const column of columns) {
group.orWhereILike(column, pattern);
}
});
}
//# sourceMappingURL=lucid_adapter.js.map