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.

148 lines 5.71 kB
import { FILTER_OPERATORS, OPERATOR_ALIASES, } from './operators.js'; const operatorSet = new Set(FILTER_OPERATORS); /** * Normalizes an operator string to its canonical {@link FilterOperator}, * resolving SQL-symbol aliases (`=` -> `equals`, `!=` -> `notEquals`, ...). * Canonical operators (and unknown strings) pass through unchanged. */ export function normalizeOperator(operator) { return (OPERATOR_ALIASES[operator] ?? operator); } /** * Operators that require no value (unary operators). */ const UNARY_OPERATORS = new Set([ 'isEmpty', 'isNotEmpty', 'isNull', 'isNotNull', 'exists', 'notExists', ]); /** * Operators that require an array value. */ const ARRAY_OPERATORS = new Set([ 'in', 'notIn', 'isAnyOf', 'between', 'notBetween', ]); /** * Maximum allowed nesting depth for AND/OR filter composition. * Prevents stack overflow from maliciously deep payloads (DoS). */ export const MAX_FILTER_DEPTH = 10; export class InvalidColumnFilterError extends Error { constructor(message) { super(message); this.name = 'InvalidColumnFilterError'; } } /** * Validates a ColumnFilter at runtime: * - field must be a non-empty string with no SQL-unsafe characters * - operator must be a known FilterOperator * - value is required for non-unary operators * - 'between' requires a 2-element array * - 'in'/'isAnyOf' require arrays * - AND/OR arrays are validated recursively */ export function validateColumnFilter(filter, depth = 0) { if (depth > MAX_FILTER_DEPTH) { throw new InvalidColumnFilterError(`Filter nesting exceeds maximum depth (${MAX_FILTER_DEPTH}).`); } if (!filter || typeof filter !== 'object') { throw new InvalidColumnFilterError('Column filter must be a non-null object.'); } // Pure group node: `{ AND: [...] }` / `{ OR: [...] }` with no column of its // own composes child filters and has no field/operator/value to validate. // The client builder emits these (sometimes with an empty `field`), so treat // an empty field as absent. Validate only the nested arrays, then stop. const isGroupNode = (filter.AND !== undefined || filter.OR !== undefined) && (filter.field === undefined || filter.field === ''); if (isGroupNode) { for (const key of ['AND', 'OR']) { const group = filter[key]; if (group !== undefined) { if (!Array.isArray(group)) { throw new InvalidColumnFilterError(`"${key}" must be an array of ColumnFilter.`); } for (const sub of group) { validateColumnFilter(sub, depth + 1); } } } return; } // Validate field if (typeof filter.field !== 'string' || filter.field.length === 0) { throw new InvalidColumnFilterError('Column filter "field" must be a non-empty string.'); } // Reject field names that contain SQL-unsafe characters if (!/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(filter.field)) { throw new InvalidColumnFilterError(`Column filter field "${filter.field}" contains invalid characters. Only letters, digits, underscores, and dots are allowed.`); } // Validate operator, accepting SQL-symbol aliases (`=`, `!=`, `<`, ...). const op = normalizeOperator(filter.operator); if (!operatorSet.has(op)) { throw new InvalidColumnFilterError(`Unknown filter operator "${filter.operator}". ` + `Valid operators: ${FILTER_OPERATORS.join(', ')}.`); } // Persist the canonical form so downstream query builders never see aliases. filter.operator = op; // Reject null for non-unary operators (point to isNull instead) if (!UNARY_OPERATORS.has(op) && filter.value === null) { throw new InvalidColumnFilterError(`Operator "${op}" received null. Use "isNull" for NULL checks.`); } // Validate value if (UNARY_OPERATORS.has(op)) { // Unary operators don't need a value — ignore any provided value } else if (op === 'between' || op === 'notBetween') { if (!Array.isArray(filter.value) || filter.value.length !== 2) { throw new InvalidColumnFilterError(`Operator "${op}" requires a value that is a 2-element array, got: ${JSON.stringify(filter.value)}.`); } } else if (ARRAY_OPERATORS.has(op)) { if (!Array.isArray(filter.value)) { throw new InvalidColumnFilterError(`Operator "${op}" requires an array value, got: ${typeof filter.value}.`); } } else { if (filter.value === undefined) { throw new InvalidColumnFilterError(`Operator "${op}" requires a value.`); } } // Validate nested AND if (filter.AND !== undefined) { if (!Array.isArray(filter.AND)) { throw new InvalidColumnFilterError('"AND" must be an array of ColumnFilter.'); } for (const sub of filter.AND) { validateColumnFilter(sub, depth + 1); } } // Validate nested OR if (filter.OR !== undefined) { if (!Array.isArray(filter.OR)) { throw new InvalidColumnFilterError('"OR" must be an array of ColumnFilter.'); } for (const sub of filter.OR) { validateColumnFilter(sub, depth + 1); } } } /** * Validates an array of ColumnFilter objects. */ export function validateColumnFilters(filters) { if (!Array.isArray(filters)) { throw new InvalidColumnFilterError('Column filters must be an array.'); } for (const filter of filters) { validateColumnFilter(filter); } } //# sourceMappingURL=validate-column-filter.js.map