@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.
164 lines • 8.72 kB
JavaScript
/** Enumerate the filterable field paths a spec admits, capped at `maxDepth` relation hops. */
export function filterableFieldPaths(spec, maxDepth = spec.maxDepth) {
return collectPaths(spec, 'filterable', maxDepth);
}
/** Enumerate the sortable field paths a spec admits, capped at `maxDepth` relation hops. */
export function sortableFieldPaths(spec, maxDepth = spec.maxDepth) {
return collectPaths(spec, 'sortable', maxDepth);
}
/**
* Walk the spec's base allow-list + relation whitelist into a flat, de-duped list
* of (dotted) field paths, bounded by `maxDepth` relation hops. A `'*'` allow-list
* cannot be enumerated (any column), so it contributes no concrete base names —
* the emitter then falls back to a permissive `string` field union.
*/
function collectPaths(spec, kind, maxDepth) {
const out = [];
const base = kind === 'filterable' ? spec.filterable : spec.sortable;
if (Array.isArray(base))
out.push(...base);
// Computed/virtual fields are both filterable and sortable — surface their
// declared aliases in the client's field union so a generated client can
// reference `fullName`/`postCount` exactly like a real column.
if (spec.computed)
out.push(...Object.keys(spec.computed));
const walk = (relations, prefix, depth) => {
if (depth > maxDepth)
return;
for (const [relName, rel] of Object.entries(relations)) {
const cols = kind === 'filterable' ? rel.filterable : (rel.sortable ?? rel.filterable);
if (Array.isArray(cols)) {
for (const col of cols)
out.push([...prefix, relName, col].join('.'));
}
if (rel.relations)
walk(rel.relations, [...prefix, relName], depth + 1);
}
};
walk(spec.relations, [], 1);
return [...new Set(out)];
}
/** `foo-bar_baz` → `FooBarBaz`. Strips non-identifier chars, PascalCases the rest. */
function toPascalCase(name) {
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
const pascal = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
return /^[0-9]/.test(pascal) ? `_${pascal}` : pascal || 'Filter';
}
/** PascalCase but a lowercased first char — the value-identifier form (`peopleFilterQuery`). */
function toCamelCase(name) {
const pascal = toPascalCase(name);
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
}
/** `BlogPost`/`blog-post` → `blog_post` — the AdonisJS file-name convention. */
function toSnakeCase(name) {
return toPascalCase(name)
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase();
}
/** Map a field's type info to a TS type literal. Mirrors the NestJS `kindToTs` emitter. */
function fieldTypeToTs(info) {
if (info.typeRef)
return info.typeRef;
if (info.enumValues && info.enumValues.length > 0) {
return info.enumValues
.map((v) => (typeof v === 'number' ? String(v) : JSON.stringify(v)))
.join(' | ');
}
switch (info.kind) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
case 'date':
return 'Date';
case 'json':
return 'Record<string, unknown>';
default:
return 'unknown';
}
}
/** The classifier kind stored in runtime meta (`typeRef`/enum collapse to their base bucket). */
function metaKind(info) {
return info.kind ?? 'unknown';
}
/**
* Generate a typed filter client module for one {@link FilterSpec} — a pure
* string transform. The emitted module exports, for a spec named `people`:
*
* - `type PeopleFilterFields` — the union of filterable field paths (the security
* boundary, as concrete string literals);
* - `interface PeopleFilterFieldTypes` — the per-field value-type map (only when
* `fieldTypes` is supplied), which drives the client's operator/value narrowing;
* - `const peopleFilterMeta` — runtime metadata (filterable/sortable/searchable
* fields, whitelisted relations, per-field kinds, and the default sort / page
* size / max size + cursor keyset the runner paginates by);
* - `function peopleFilterQuery()` — a `filterQueryTyped<Fields, FieldTypes>()`
* factory returning a type-safe builder scoped to this spec.
*/
export function generateFilterClient(spec, options) {
const pascal = toPascalCase(options.name);
const camel = toCamelCase(options.name);
const clientModule = options.clientModule ?? '@adonis-agora/filter-client';
const maxDepth = options.maxDepth ?? spec.maxDepth;
// The spec's own `fieldTypes` are the default source: declaring a kind there already drives
// server-side value coercion, so inheriting it here means one declaration feeds both ends
// instead of asking callers to repeat themselves in the manifest. An explicit `options.fieldTypes`
// still wins, for a caller that wants richer codegen-only info (enumValues/typeRef) than the
// server needs.
const fieldTypes = options.fieldTypes ?? spec.fieldTypes ?? {};
const hasTypes = Object.keys(fieldTypes).length > 0;
const fields = filterableFieldPaths(spec, maxDepth);
const sortable = sortableFieldPaths(spec, maxDepth);
const relations = Object.keys(spec.relations);
const cursorKeyset = spec.defaultSort.map((s) => s.field);
const fieldsUnion = fields.length > 0 ? fields.map((f) => JSON.stringify(f)).join(' | ') : 'string';
const typesTypeName = `${pascal}FilterFieldTypes`;
const fieldsTypeName = `${pascal}FilterFields`;
const typeArgs = hasTypes ? `${fieldsTypeName}, ${typesTypeName}` : fieldsTypeName;
const lines = [];
if (options.banner ?? true) {
lines.push('// Code generated by @adonis-agora/filter. DO NOT EDIT.', `// Source: filter spec "${options.name}".`, '');
}
lines.push(`import { filterQueryTyped as _filterQueryTyped } from '${clientModule}';`, '', `/** Filterable field paths for \`${options.name}\` — the client's field-name allow-list. */`, `export type ${fieldsTypeName} = ${fieldsUnion};`, '');
if (hasTypes) {
lines.push(`/** Per-field value types for \`${options.name}\` (drives operator narrowing). */`);
lines.push(`export interface ${typesTypeName} {`);
for (const [field, info] of Object.entries(fieldTypes)) {
let ts = fieldTypeToTs(info);
if (info.nullable)
ts = `${ts} | null`;
lines.push(` ${JSON.stringify(field)}: ${ts};`);
}
lines.push('}', '');
}
const typesMeta = hasTypes
? `{ ${Object.entries(fieldTypes)
.map(([field, info]) => `${JSON.stringify(field)}: ${JSON.stringify(metaKind(info))}`)
.join(', ')} }`
: '{}';
lines.push(`/** Runtime filter metadata for \`${options.name}\`. */`, `export const ${camel}FilterMeta = {`, ` fields: [${fields.map((f) => JSON.stringify(f)).join(', ')}],`, ` sortable: [${sortable.map((f) => JSON.stringify(f)).join(', ')}],`, ` searchable: [${spec.searchable.map((f) => JSON.stringify(f)).join(', ')}],`, ` relations: [${relations.map((r) => JSON.stringify(r)).join(', ')}],`, ` types: ${typesMeta},`, ` defaultSort: [${spec.defaultSort
.map((s) => `{ field: ${JSON.stringify(s.field)}, direction: ${JSON.stringify(s.direction)} }`)
.join(', ')}],`, ` cursorKeyset: [${cursorKeyset.map((f) => JSON.stringify(f)).join(', ')}],`, ` defaultSize: ${spec.defaultSize ?? 'undefined'},`, ` maxSize: ${spec.maxSize ?? 'undefined'},`, '} as const;', '', `/** A type-safe filter-query builder scoped to \`${options.name}\`'s filterable fields. */`, `export function ${camel}FilterQuery() {`, ` return _filterQueryTyped<${typeArgs}>();`, '}', '');
return lines.join('\n');
}
/**
* Expand a whole {@link FilterClientManifest} into per-spec generated modules —
* the pure core the `make:filter-client` ace command writes to disk. Kept pure
* (manifest in, modules out) so the command stays a thin IO wrapper and the
* expansion is itself testable without a running app.
*/
export function generateFilterClients(manifest) {
return Object.entries(manifest).map(([name, entry]) => ({
name,
filename: `${toSnakeCase(name)}_filter_client.ts`,
code: generateFilterClient(entry.spec, {
name,
...(entry.fieldTypes && { fieldTypes: entry.fieldTypes }),
...(entry.clientModule && { clientModule: entry.clientModule }),
...(entry.maxDepth !== undefined && { maxDepth: entry.maxDepth }),
}),
}));
}
//# sourceMappingURL=generate_client.js.map