@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.
127 lines • 6.27 kB
JavaScript
import { methodForKey } from './filter_class.js';
import { FILTER_OPERATORS } from './operators.js';
import { parseFilterRequest } from './parse_request.js';
import { InvalidColumnFilterError, MAX_FILTER_DEPTH, normalizeOperator, } from './validate-column-filter.js';
/**
* Resolve a custom filter class through the request's IoC container when there is one — the same
* resolver a controller is constructed with, so `@inject()` on the filter behaves identically —
* and fall back to plain construction outside an AdonisJS request (a test, a script).
*/
async function resolveCustomFilter(cls, ctx) {
const resolver = ctx?.containerResolver;
if (resolver && typeof resolver.make === 'function') {
return (await resolver.make(cls));
}
return new cls();
}
/**
* The method owning `field`: an exact `@filterFor`/own-method match first, else the head segment
* (`attr.tier` reaches `attr`) so one method can own a whole dynamic subtree. The full field still
* rides along as the method's third argument, so the method always knows which key it was reached
* through.
*
* The cast is sound: method dispatch only walks the prototype chain and reads static knobs, both
* of which a custom filter class carries exactly like a model filter class (the walk simply ends
* at `Object.prototype` instead of `BaseModelFilter.prototype`, with the same result — and at
* `BaseFilter.prototype` for the shared members, which are never dispatchable).
*/
function resolveMethod(cls, field) {
const asModelFilter = cls;
const exact = methodForKey(asModelFilter, field);
if (exact !== undefined)
return exact;
const dot = field.indexOf('.');
if (dot > 0)
return methodForKey(asModelFilter, field.slice(0, dot));
return undefined;
}
/**
* Collect the calls a parsed input dispatches to: every structured filter to its owning method
* (`AND` groups recurse; `OR` is rejected — a draft is an ANDed predicate bag and cannot express
* cross-field OR), then every bare top-level key the wire format does not own (`?tag=etl`, the
* legacy spelling) to its same-named method. Unknown BARE keys are ignored — they are how old
* callers send pagination and other endpoint mechanics — while an unknown STRUCTURED field is
* rejected outright: the new spelling fails loudly on typos instead of silently widening.
*/
function collectCalls(cls, raw, parsed) {
const calls = [];
const visit = (filter, depth) => {
if (depth > MAX_FILTER_DEPTH) {
throw new InvalidColumnFilterError(`Filter nesting exceeds maximum depth (${MAX_FILTER_DEPTH}).`);
}
const isGroupNode = (filter.AND !== undefined || filter.OR !== undefined) &&
(filter.field === undefined || filter.field === '');
if (isGroupNode) {
if (filter.OR !== undefined) {
throw new InvalidColumnFilterError('This filter does not support OR groups — a draft is an ANDed predicate bag.');
}
for (const sub of filter.AND ?? [])
visit(sub, depth + 1);
return;
}
if (typeof filter.field !== 'string' || filter.field === '') {
throw new InvalidColumnFilterError('Column filter needs a field.');
}
// SQL-symbol aliases (`=`, `!=`, …) normalize to canonical operators first, so methods only
// ever see the canonical spelling — the same normalization the SQL path applies.
const operator = normalizeOperator(String(filter.operator));
if (!FILTER_OPERATORS.includes(operator)) {
throw new InvalidColumnFilterError(`Unknown filter operator: "${String(filter.operator)}".`);
}
const method = resolveMethod(cls, filter.field);
if (method === undefined) {
throw new InvalidColumnFilterError(`Unknown filter field: "${filter.field}".`);
}
calls.push({ method, value: filter.value, operator, field: filter.field });
};
for (const filter of parsed.filters ?? [])
visit(filter, 0);
// A bare top-level key (`?tag=etl`) reaches a method of the same name too — the legacy spelling
// — as long as the wire format does not own the key and no structured filter already claimed it.
// The reserved gate inside `methodForKey` is what keeps endpoint mechanics (`limit`, `sort`, …)
// from ever reaching a method here.
for (const [key, value] of Object.entries(raw)) {
const method = resolveMethod(cls, key);
if (method !== undefined && !calls.some((call) => call.method === method)) {
calls.push({ method, value, operator: 'equals', field: key });
}
}
return calls;
}
/**
* Run a custom filter class against a caller-created draft from a request context: bind the
* per-request state onto the instance, run `setup()`, then hand each filter to its owning method
* as `(value, operator, field)`.
*
* The custom-backend counterpart of
* {@link import('./apply_from_request.js').applyFilterFromRequest}: that helper ends in SQL (a
* `QueryBuilderLike`), this one ends in whatever the draft accumulates — a `RunQuery`, an engine
* client query, an in-memory predicate. The wire format, the parsing, the method dispatch and the
* error shapes are shared, so a console can move between a Lucid listing and a custom-backend one
* without relearning the spelling.
*
* ```ts
* const draft = new RunQueryDraft();
* await applyCustomFilter(draft, RunFilter, ctx);
* const runs = await engine.listRuns(draft.query);
* ```
*/
export async function applyCustomFilter(draft, cls, ctx, options = {}) {
const raw = ctx?.request?.qs?.() ?? {};
const parsed = options.input ?? parseFilterRequest(raw);
const instance = await resolveCustomFilter(cls, ctx);
Object.assign(instance, {
$query: draft,
$input: raw,
$parsed: parsed,
$ctx: ctx,
});
await instance.setup?.();
for (const { method, value, operator, field } of collectCalls(cls, raw, parsed)) {
const fn = instance[method];
if (typeof fn === 'function') {
await fn.call(instance, value, operator, field);
}
}
}
//# sourceMappingURL=custom_filter.js.map