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.

244 lines 8.65 kB
import { BaseFilter } from './base_filter.js'; import { BaseModelFilter } from './base_model_filter.js'; import { readDecorators } from './decorator_metadata.js'; import { defineFilter } from './filter_spec.js'; /** * A filter class's static side. Read through a cast rather than declared on {@link FilterClass} * itself: an index signature there would make every concrete `class UserFilter extends * BaseModelFilter` *unassignable* to the type — `static $filter = () => UserFilter` on a model * would stop compiling. */ function statics(cls) { return cls; } /** Request keys the wire format owns — never dispatched to a method of the same name. */ const RESERVED_KEYS = new Set([ 'filter', 'filters', 'sort', 'search', 'page', 'size', 'distinct', 'include', 'select', 'after', 'before', 'first', 'last', 'limit', 'offset', ]); /** Names on the class that are machinery, not request keys. */ const RESERVED_METHODS = new Set(['constructor', 'setup', 'input']); /** * Is this a filter class (as opposed to a `defineFilter` spec)? Checked structurally — a class * that extends {@link BaseModelFilter} — so the two forms can share one entry point. */ export function isFilterClass(value) { if (typeof value !== 'function') return false; let proto = Object.getPrototypeOf(value); while (proto !== null && proto !== Function.prototype) { if (proto === BaseModelFilter) return true; proto = Object.getPrototypeOf(proto); } return false; } /** * The request keys a class owns with a method of its own, walking the prototype chain up to (but * not including) {@link BaseModelFilter} — so a shared abstract filter's methods are inherited * like any other. `setup`, the constructor, `$`-prefixed members and anything on the class's * `blacklist` are excluded. * * Memoized per class: the shape of a class does not change between requests. */ export function dispatchKeys(cls) { const cached = KEY_CACHE.get(cls); if (cached) return cached; const blacklist = new Set(statics(cls).blacklist ?? []); const bound = explicitBindings(cls).methods; const keys = new Set(); let proto = cls.prototype; // Stop at either base: members the bases themselves define (`input`, `setup`) are machinery, // never dispatchable keys — for model and custom filters alike. while (proto !== null && proto !== BaseModelFilter.prototype && proto !== BaseFilter.prototype && proto !== Object.prototype) { for (const name of Object.getOwnPropertyNames(proto)) { if (RESERVED_METHODS.has(name) || blacklist.has(name) || bound.has(name)) continue; if (name.startsWith('$') || name.startsWith('_')) continue; const descriptor = Object.getOwnPropertyDescriptor(proto, name); if (typeof descriptor?.value === 'function') keys.add(name); } proto = Object.getPrototypeOf(proto); } KEY_CACHE.set(cls, keys); return keys; } /** * The `@filterFor` bindings a class declares: request key → method, and the set of methods that * are bound (and so no longer answer to their own name). * * Memoized per class alongside the dispatch keys — decorators run at class-definition time, long * before the first request reaches the class. */ function explicitBindings(cls) { const cached = BINDING_CACHE.get(cls); if (cached) return cached; const keys = new Map(); const methods = new Set(); const blacklist = new Set(statics(cls).blacklist ?? []); for (const [method, bound] of Object.entries(readDecorators(cls).filterFor ?? {})) { if (blacklist.has(method)) continue; methods.add(method); for (const key of bound) keys.set(key, method); } const bindings = { keys, methods }; BINDING_CACHE.set(cls, bindings); return bindings; } const BINDING_CACHE = new WeakMap(); const KEY_CACHE = new WeakMap(); const SPEC_CACHE = new WeakMap(); /** * Compile a filter class's static declarations into the {@link FilterSpec} the runner consumes — * the same object `defineFilter` produces, so a class and a spec take the identical code path * through allow-listing, search, sort and pagination. * * A class that declares no `filterable` is method-only: nothing reaches SQL except through the * methods it wrote, which is the tightest allow-list there is. * * Memoized per class — the declaration is static, so it is compiled once per process. */ export function specFromFilterClass(cls) { const cached = SPEC_CACHE.get(cls); if (cached) return cached; const declared = declaredByModel(cls); const spec = defineFilter({ ...declared, filterable: statics(cls).filterable ?? declared.filterable ?? [], ...pick(cls, [ 'model', 'sortable', 'searchable', 'relations', 'aliases', 'computed', 'fieldTypes', 'fullText', 'vectorSimilarity', 'tenant', 'defaultFilters', 'defaultSort', 'defaultSize', 'maxSize', 'maxDepth', 'table', 'throwOnInvalid', ]), }); SPEC_CACHE.set(cls, spec); return spec; } /** * What the model itself declares through `@filterable` / `@sortable` / `@searchable` on its * columns, read off the filter's `static model`. * * These are **fallbacks**: a static on the filter class replaces the corresponding list outright, * so a stricter filter over a shared model can narrow what the model opens up. Only `fieldTypes` * merges per field — it is metadata about a column, not a decision about who may reach it. */ function declaredByModel(cls) { const model = statics(cls).model; if (!model) return {}; const declared = readDecorators(model); const out = {}; if (declared.filterable) { out.filterable = Object.keys(declared.filterable); const fieldTypes = {}; for (const [column, kind] of Object.entries(declared.filterable)) { if (kind !== null) fieldTypes[column] = { kind }; } const merged = { ...fieldTypes, ...statics(cls).fieldTypes }; if (Object.keys(merged).length > 0) out.fieldTypes = merged; } if (declared.sortable) out.sortable = [...new Set(declared.sortable)]; if (declared.searchable) out.searchable = [...new Set(declared.searchable)]; return out; } function pick(cls, names) { const out = {}; const statics = cls; for (const name of names) { const value = statics[name]; if (value !== undefined) out[name] = value; } return out; } /** * Find the method a request key maps to, honouring the class's `camelCase` (a `snake_case` key * matches a camelCase method — on by default) and `dropId` (`companyId` matches `company`) knobs. * Returns the method name, or `undefined` when the class does not own the key. */ export function methodForKey(cls, key) { if (typeof key !== 'string' || key.length === 0 || RESERVED_KEYS.has(key)) return undefined; const bound = explicitBindings(cls).keys.get(key); if (bound !== undefined) return bound; const keys = dispatchKeys(cls); if (keys.has(key)) return key; const knobs = statics(cls); if (knobs.camelCase !== false) { const camel = toCamelCase(key); if (keys.has(camel)) return camel; if (knobs.dropId === true) { const dropped = stripId(camel); if (dropped.length > 0 && keys.has(dropped)) return dropped; } } if (knobs.dropId === true) { const dropped = stripId(key); if (dropped.length > 0 && keys.has(dropped)) return dropped; } return undefined; } function toCamelCase(key) { const result = key.replace(/[_-](\w)/g, (_, char) => char.toUpperCase()); return result.charAt(0).toLowerCase() + result.slice(1); } function stripId(key) { if (key === 'id') return ''; if (key.endsWith('Id')) return key.slice(0, -2); if (key.endsWith('_id')) return key.slice(0, -3); return key; } /** Is this key one the wire format owns (so it is never dispatched to a method)? */ export function isReservedKey(key) { return RESERVED_KEYS.has(key); } //# sourceMappingURL=filter_class.js.map