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.

182 lines 8.57 kB
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