@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.
220 lines • 8.89 kB
JavaScript
function toInt(value) {
if (typeof value === 'number' && Number.isFinite(value))
return value;
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value);
if (Number.isFinite(n))
return n;
}
return undefined;
}
/** Map one `filter[field]=…` entry to `ColumnFilter[]` (Spatie/JSON:API shapes). */
export function toColumnFilters(field, value) {
// Array (`filter[id][]=1&filter[id][]=2`) → IN.
if (Array.isArray(value)) {
return [{ field, operator: 'in', value }];
}
// Operator object (`filter[age][gte]=18`) → one filter per operator key.
if (value != null && typeof value === 'object') {
return Object.entries(value).map(([operator, opValue]) => ({
field,
operator: operator,
value: opValue,
}));
}
// Comma-separated scalar (Spatie multi-value convention) → IN.
if (typeof value === 'string' && value.includes(',')) {
return [
{
field,
operator: 'in',
value: value
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0),
},
];
}
// Bare scalar → equals.
return [{ field, operator: 'equals', value }];
}
/**
* True when `value` is an already-structured `ColumnFilter[]` rather than the
* `filter[field]=…` shape `toColumnFilters` reshapes.
*
* Two wire forms produce it. A POST body carrying the client builder's
* `build()` output nests it under `filter.where`; a GET whose query string was
* serialized from OR/AND groups decodes to the same array of
* `{ field, operator, value }` records. Both used to fall through to the
* scalar path, where an array becomes a single `in` filter on a field literally
* named `where` — dropped by the allow-list, leaving the query unfiltered.
*
* A real column named `where` arrives as `filter[where]=x` (a string) or
* `filter[where][gte]=1` (an operator object), so neither is mistaken for this.
*/
function isStructuredFilterList(value) {
return (Array.isArray(value) &&
value.length > 0 &&
value.every((entry) => entry != null &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof entry.field === 'string' &&
typeof entry.operator === 'string'));
}
/**
* Parse the `distinct` param into a de-duplicated list of field names. Accepts a
* comma-separated string (`distinct=afsc,base`) or a repeated/array form
* (`distinct[]=afsc&distinct[]=base`) — the shapes the client's `toQueryString()`
* and structured `build()` emit. Non-string entries are ignored.
*/
export function parseDistinct(distinct) {
let raw = [];
if (typeof distinct === 'string')
raw = distinct.split(',');
else if (Array.isArray(distinct))
raw = distinct.filter((s) => typeof s === 'string');
const out = [];
for (const entry of raw) {
const field = entry.trim();
if (field.length > 0 && !out.includes(field))
out.push(field);
}
return out;
}
/**
* Parse the `sort` param into ordered {@link SortItem}s. Accepts the string form
* (`-createdAt,name` or `sort[]=name`) and the already-structured
* `[{ field, direction }]` form the client builder's `build()` emits — the
* latter used to be filtered out entirely as "not a string", silently dropping
* the ordering.
*/
export function parseSort(sort) {
if (Array.isArray(sort) && sort.some((s) => s != null && typeof s === 'object')) {
const items = [];
for (const entry of sort) {
if (entry == null || typeof entry !== 'object')
continue;
const { field, direction } = entry;
if (typeof field !== 'string' || field.length === 0)
continue;
items.push({ field, direction: direction === 'desc' ? 'desc' : 'asc' });
}
return items;
}
let raw = [];
if (typeof sort === 'string')
raw = sort.split(',');
else if (Array.isArray(sort))
raw = sort.filter((s) => typeof s === 'string');
const items = [];
for (const entry of raw) {
const field = entry.trim();
if (field.length === 0)
continue;
if (field.startsWith('-'))
items.push({ field: field.slice(1), direction: 'desc' });
else
items.push({ field, direction: 'asc' });
}
return items;
}
function parsePagination(qs) {
let page = toInt(qs.page);
let size = toInt(qs.size);
// JSON:API nested form: page[number] / page[size].
if (qs.page != null && typeof qs.page === 'object' && !Array.isArray(qs.page)) {
const p = qs.page;
page = toInt(p.number) ?? page;
size = toInt(p.size) ?? size;
}
// The client builder's `build()` nests the same two numbers under `paginate`,
// which is what a POST search body carries.
if (qs.paginate != null && typeof qs.paginate === 'object' && !Array.isArray(qs.paginate)) {
const p = qs.paginate;
page = toInt(p.page) ?? page;
size = toInt(p.size) ?? size;
}
return { ...(page !== undefined && { page }), ...(size !== undefined && { size }) };
}
/**
* Parse a decoded request query object — e.g. AdonisJS `ctx.request.qs()` — into
* a structured {@link FilterInput}. Understands the Spatie / JSON:API shapes the
* `@adonis-agora/filter-client` builder emits:
*
* - `filter[status]=active` → equals
* - `filter[id]=1,2,3` / `filter[id][]=1&filter[id][]=2` → IN
* - `filter[age][gte]=18` → operator filter
* - `sort=-createdAt,name` → sort items
* - `distinct=afsc,base` / `distinct[]=afsc&distinct[]=base` → distinct fields
* - `groupByCount[field]=tag&groupByCount[limit]=20` → value enumeration
* - `search=term`, `page`/`size` (or `page[number]`/`page[size]`)
*
* It also accepts the structured shape the client builder's `build()` returns —
* `{ filter: { where: [...] }, sort: [{ field, direction }], paginate: { page, size } }`
* — so a POST search body can be handed straight in, and so OR/AND groups (which
* serialize to a top-level `where[0][field]=…`) survive the round trip.
* `include` is not consumed here: eager-loading is the caller's `preload` call.
*
* Pure reshape — no validation or allow-listing here; that happens in
* {@link applyFilter} against the {@link FilterConfig}.
*/
export function parseFilterRequest(qs) {
const out = {};
const filters = [];
if (qs.filter != null && typeof qs.filter === 'object' && !Array.isArray(qs.filter)) {
for (const [field, value] of Object.entries(qs.filter)) {
if (field === 'where' && isStructuredFilterList(value)) {
filters.push(...value);
continue;
}
filters.push(...toColumnFilters(field, value));
}
}
// OR/AND groups serialize to a top-level `where[0][field]=…`, with no `filter`
// wrapper, so they decode beside `filter` rather than inside it.
if (isStructuredFilterList(qs.where))
filters.push(...qs.where);
if (filters.length > 0)
out.filters = filters;
const sort = parseSort(qs.sort);
if (sort.length > 0)
out.sort = sort;
const distinct = parseDistinct(qs.distinct);
if (distinct.length > 0)
out.distinct = distinct;
if (typeof qs.search === 'string' && qs.search.length > 0)
out.search = qs.search;
const { page, size } = parsePagination(qs);
if (page !== undefined)
out.page = page;
if (size !== undefined)
out.size = size;
const groupByCount = parseGroupByCount(qs.groupByCount);
if (groupByCount !== undefined)
out.groupByCount = groupByCount;
return out;
}
/**
* Parse the `groupByCount` block the client builder emits
* (`groupByCount[field]=tag&groupByCount[limit]=20&groupByCount[offset]=…&groupByCount[search]=…`).
* Returns `undefined` when no usable block is present. Pure reshape, like the rest of this
* module — field validation happens at execution, against the allow-list.
*/
export function parseGroupByCount(value) {
if (value == null || typeof value !== 'object' || Array.isArray(value))
return undefined;
const block = value;
if (typeof block.field !== 'string' || block.field.trim() === '')
return undefined;
const out = { field: block.field };
const limit = toInt(block.limit);
if (limit !== undefined)
out.limit = limit;
const offset = toInt(block.offset);
if (offset !== undefined)
out.offset = offset;
if (typeof block.search === 'string' && block.search.length > 0)
out.search = block.search;
return out;
}
//# sourceMappingURL=parse_request.js.map