@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
JavaScript
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