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.

82 lines 4.21 kB
/** * Coerce a raw filter value to its declared column kind, or reject it. * * Why this exists: a filter value arriving over a query string is ALWAYS a string * (`?filter[dayOfWeek][equals]=3` yields `'3'`). Postgres papers over the benign cases with an * implicit cast — `day_of_week = '3'` and `is_recurring = 'false'` both work — so the gap is * invisible until a client sends something that ISN'T castable: `is_recurring = 'xyz'` raises * `invalid input syntax for type boolean` at the database, which surfaces as a 500 on a public * endpoint from pure user input. The allow-list guards which FIELD may be filtered; this guards * what VALUE may reach the column. * * Rejection is not an exception here: the caller maps it onto the spec's existing `throwOnInvalid` * semantics, so a bad value behaves exactly like a disallowed field — dropped by default, or a * loud `InvalidColumnFilterError` (→ 400) when the spec asks for it. */ export function coerceFilterValue(value, kind) { // `null` is meaningful for every kind (`equals: null` is a legitimate IS NULL comparison) and // never triggers a cast error, so it short-circuits before any kind-specific parsing. if (value === null) return { ok: true, value: null }; switch (kind) { case 'number': { if (typeof value === 'number') { return Number.isFinite(value) ? { ok: true, value } : { ok: false, reason: 'not a finite number' }; } if (typeof value === 'string') { const trimmed = value.trim(); // Number('') === 0 and Number(' ') === 0 — both would silently become a valid 0, so an // empty string is rejected up front rather than coerced into a filter nobody asked for. if (trimmed === '') return { ok: false, reason: 'empty string is not a number' }; const n = Number(trimmed); // Number('12abc') is NaN, which is what rejects partial garbage. return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, reason: 'not a number' }; } return { ok: false, reason: 'not a number' }; } case 'boolean': { if (typeof value === 'boolean') return { ok: true, value }; if (typeof value === 'string') { const v = value.trim().toLowerCase(); if (v === 'true' || v === '1') return { ok: true, value: true }; if (v === 'false' || v === '0') return { ok: true, value: false }; return { ok: false, reason: 'not a boolean' }; } return { ok: false, reason: 'not a boolean' }; } case 'date': { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? { ok: false, reason: 'invalid date' } : { ok: true, value }; } if (typeof value === 'string') { const t = Date.parse(value); // The string is handed back VERBATIM rather than as a Date: the driver already parses ISO // strings, and converting here would silently re-zone a date-only value ('2026-07-15' // becomes midnight UTC, which shifts the day for any negative-offset client). Date.parse is // used only to reject, never to rewrite. return Number.isNaN(t) ? { ok: false, reason: 'invalid date' } : { ok: true, value }; } return { ok: false, reason: 'not a date' }; } case 'string': { if (typeof value === 'string') return { ok: true, value }; if (typeof value === 'number' || typeof value === 'boolean') { return { ok: true, value: String(value) }; } return { ok: false, reason: 'not a string' }; } // No column-level contract to enforce — pass through untouched. default: return { ok: true, value }; } } //# sourceMappingURL=coerce_value.js.map