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.

164 lines 8.72 kB
/** 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