@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.
182 lines • 8.57 kB
JavaScript
import { discoverAggregateSources } from './aggregate.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 class FilterDefinitionError extends Error {
constructor(message) {
super(message);
this.name = 'FilterDefinitionError';
}
}
/** True for the colocated map form (a plain object, not an array and not `'*'`). */
function isFilterableMap(input) {
return typeof input === 'object' && input !== null && !Array.isArray(input);
}
/** Desugar `filterable` to the allow-list the rest of the lib consumes. */
function normalizeFilterable(input) {
return isFilterableMap(input) ? Object.keys(input) : input;
}
/** Extract the `fieldTypes` a colocated `filterable` map declares, or undefined for other forms. */
function fieldTypesFromFilterable(input) {
if (!isFilterableMap(input))
return undefined;
const out = {};
for (const [field, kind] of Object.entries(input))
out[field] = { kind };
return out;
}
/** Blocked path segments that could otherwise index inherited prototype members. */
const BLOCKED_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf']);
/** Deepest relation nesting declared under `relations` (0 when none). */
function declaredDepth(relations) {
if (!relations)
return 0;
let max = 0;
for (const rel of Object.values(relations)) {
max = Math.max(max, 1 + declaredDepth(rel.relations));
}
return max;
}
/** Does an allow-list admit a bare column name? `undefined` → treated as `'*'`. */
function columnAllowed(list, column) {
if (list === undefined || list === '*')
return true;
return list.includes(column);
}
/**
* Resolve a (possibly dotted) field path against the base allow-lists and the
* relation whitelist, enforcing `maxDepth`. Pure — the client-supplied `field`
* is only ever read, and each segment is guarded against prototype-pollution
* lookups before it indexes the relations map.
*/
function pathAllowed(field, kind, baseFilterable, baseSortable, relations, maxDepth) {
const segments = field.split('.');
// Base column (no relation hop).
if (segments.length === 1) {
const seg = segments[0];
if (seg.length === 0 || BLOCKED_SEGMENTS.has(seg))
return false;
return columnAllowed(kind === 'filterable' ? baseFilterable : baseSortable, seg);
}
// Relation path — bounded by maxDepth (hops = segments - 1).
if (segments.length - 1 > maxDepth)
return false;
let node = relations;
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i];
if (seg.length === 0 || BLOCKED_SEGMENTS.has(seg))
return false;
if (!node || !Object.hasOwn(node, seg))
return false;
const rel = node[seg];
// Last hop: the final segment is the leaf column on this relation.
if (i === segments.length - 2) {
const leaf = segments[segments.length - 1];
if (leaf.length === 0 || BLOCKED_SEGMENTS.has(leaf))
return false;
const list = kind === 'filterable' ? rel.filterable : (rel.sortable ?? rel.filterable);
return columnAllowed(list, leaf);
}
node = rel.relations;
}
return false;
}
/**
* 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 function defineFilter(options) {
if (!options || options.filterable === undefined) {
throw new FilterDefinitionError('defineFilter requires a `filterable` allow-list.');
}
if (options.maxDepth !== undefined &&
(!Number.isInteger(options.maxDepth) || options.maxDepth < 0)) {
throw new FilterDefinitionError('`maxDepth` must be a non-negative integer.');
}
const relations = options.relations ?? {};
// The colocated map form desugars here, at the boundary: the keys become the allow-list and the
// values become `fieldTypes`. Everything downstream (the predicates, the runner, the codegen)
// keeps seeing the array form it always saw, so this is purely an authoring convenience.
const filterable = normalizeFilterable(options.filterable);
const sortable = options.sortable ?? filterable;
const declaredTypes = fieldTypesFromFilterable(options.filterable);
// An explicit `fieldTypes` entry wins per field: the map form can only express a bare kind, so
// this is how a caller adds codegen-only richness (enumValues/typeRef) on top of it.
const fieldTypes = declaredTypes || options.fieldTypes ? { ...declaredTypes, ...options.fieldTypes } : undefined;
const maxDepth = options.maxDepth ?? declaredDepth(relations);
// Root table (the correlated-subquery outer alias): explicit `table` wins,
// else the model's own table name.
const table = options.table ?? options.model?.table;
// Discover to-many aggregate computed sources from the model's relation
// metadata, then let dev-declared `computed` win on any key collision. The
// discovery is capability-gated (no model → empty map) and per-relation
// fault-tolerant, so this never throws at wiring time.
const aggregateSources = discoverAggregateSources(options.model, relations);
const computed = Object.keys(aggregateSources).length > 0 || options.computed
? { ...aggregateSources, ...options.computed }
: undefined;
const spec = {
filterable,
sortable,
searchable: options.searchable ?? [],
fieldTypes,
fullText: options.fullText,
relations,
maxDepth,
aliases: options.aliases,
vectorSimilarity: options.vectorSimilarity,
computed,
table,
tenant: options.tenant,
defaultFilters: options.defaultFilters ?? [],
defaultSort: options.defaultSort ?? [],
defaultSize: options.defaultSize,
maxSize: options.maxSize,
throwOnInvalid: options.throwOnInvalid ?? false,
isFilterable(field) {
return pathAllowed(field, 'filterable', filterable, sortable, relations, maxDepth);
},
isSortable(field) {
return pathAllowed(field, 'sortable', filterable, sortable, relations, maxDepth);
},
};
return Object.freeze(spec);
}
/**
* 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 function specToFilterConfig(spec) {
const defaultSortFields = new Set(spec.defaultSort.map((s) => s.field));
const allowed = (field) => spec.isFilterable(field);
const sortable = (field) => spec.isSortable(field) || defaultSortFields.has(field);
return {
allowed,
sortable,
...(spec.searchable.length > 0 && { searchable: [...spec.searchable] }),
...(spec.fieldTypes && { fieldTypes: spec.fieldTypes }),
...(spec.fullText && { fullText: spec.fullText }),
...(spec.aliases && { aliases: spec.aliases }),
...(spec.vectorSimilarity && { vectorSimilarity: spec.vectorSimilarity }),
...(spec.computed && { computed: spec.computed }),
...(spec.table !== undefined && { table: spec.table }),
...(spec.defaultSize !== undefined && { defaultSize: spec.defaultSize }),
...(spec.maxSize !== undefined && { maxSize: spec.maxSize }),
throwOnInvalid: spec.throwOnInvalid,
};
}
//# sourceMappingURL=filter_spec.js.map