UNPKG

@dudousxd/nestjs-filter-drizzle

Version:

Drizzle ORM adapter for @dudousxd/nestjs-filter.

192 lines • 8.51 kB
import { MAX_FILTER_DEPTH, escapeLike, normalizeOperator } from '@dudousxd/nestjs-filter'; import { Column, and, between, eq, gt, gte, ilike, inArray, is, isNotNull, isNull, like, lt, lte, ne, notBetween, notIlike, notInArray, notLike, or, sql, } from 'drizzle-orm'; import { classifyColumn } from './schema-metadata.js'; /** * Coerces a client value to what the column's driver encoder expects. * * Drizzle binds a comparison value THROUGH the column (`mapToDriverValue`), so * a value of the wrong JS type is not merely compared loosely — it can break * the encoder: a `timestamp` column in `mode: 'date'` calls `.toISOString()` on * whatever it is handed, and a SQLite boolean encodes the string `'false'` as * truthy `1`. Query strings and decoded cursors deliver exactly those strings. * Values that do not parse are passed through untouched (the database then * reports the mismatch, rather than this layer inventing a value). */ export function coerceValue(column, value) { if (Array.isArray(value)) return value.map((v) => coerceValue(column, v)); if (value === null || value === undefined) return value; const dataType = String(column.dataType); if (dataType === 'date' && (typeof value === 'string' || typeof value === 'number')) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date; } if (dataType === 'number' && typeof value === 'string' && value.trim() !== '') { const n = Number(value); return Number.isFinite(n) ? n : value; } if (dataType === 'boolean' && typeof value === 'string') { if (value === 'true' || value === '1') return true; if (value === 'false' || value === '0') return false; } if (dataType === 'boolean' && typeof value === 'number') return value !== 0; return value; } function isStringTarget(target) { return is(target, Column) && classifyColumn(target) === 'string'; } /** * Casts a non-string column to text before a LIKE, so `contains` on an integer * column matches its digits instead of failing (`integer ~~ unknown` on * Postgres). String columns and SQL expressions are compared as they are. */ function likeOperand(target, dialect) { if (!is(target, Column) || isStringTarget(target)) return target; return dialect === 'mysql' ? sql `cast(${target} as char)` : sql `cast(${target} as text)`; } /** * Builds a LIKE predicate. The pattern is always a bound parameter, already * escaped with {@link escapeLike} (backslash escapes). Postgres and MySQL treat * backslash as the default LIKE escape; SQLite has NO default escape character, * so the clause is spelled out there — without it `%` in user input would still * be a wildcard. * * Case-insensitive matching uses `ILIKE` on Postgres (index-friendly with a * trigram index) and `lower(x) LIKE lower(p)` elsewhere. */ export function likeCondition(target, pattern, dialect, opts = {}) { const operand = likeOperand(target, dialect); if (dialect === 'postgres') { if (opts.caseInsensitive) { return opts.negate ? notIlike(operand, pattern) : ilike(operand, pattern); } return opts.negate ? notLike(operand, pattern) : like(operand, pattern); } const left = opts.caseInsensitive ? sql `lower(${operand})` : sql `${operand}`; const right = opts.caseInsensitive ? sql `lower(${pattern})` : sql `${pattern}`; const escapeClause = dialect === 'sqlite' ? sql.raw(` escape '\\'`) : sql ``; const keyword = opts.negate ? sql.raw(' not like ') : sql.raw(' like '); return sql `${left}${keyword}${right}${escapeClause}`; } /** * Translates ONE `ColumnFilter` operator into a drizzle SQL condition over * `target`. Every client value is a bound parameter (drizzle's `eq`/`gt`/… * bind through the column's encoder); nothing client-supplied is inlined. * * `isEmpty`/`isNotEmpty` compare against `''` only for string columns — `col = * ''` on a date or integer column is a type error on Postgres/MySQL — and * collapse to the NULL check everywhere else. */ export function buildOperatorCondition(target, filter, dialect, /** * For an expression target whose values are those of a known column (a * `MIN`/`MAX` over a child column), bind comparison values through that * column's encoder — so a date compares as the column stores dates (an epoch * integer on SQLite, a timestamp on Postgres) rather than as a raw JS value. */ encoder) { const operator = normalizeOperator(filter.operator); // drizzle's operators are overloaded per operand kind (column / SQL); both // render identically, so the union is narrowed to one overload here. const t = target; let value; if (is(target, Column)) { value = coerceValue(target, filter.value); } else if (encoder) { const bind = (v) => (v === null ? v : sql.param(coerceValue(encoder, v), encoder)); value = Array.isArray(filter.value) ? filter.value.map(bind) : bind(filter.value); } else { value = filter.value; } switch (operator) { case 'equals': return value === null ? isNull(t) : eq(t, value); case 'notEquals': return value === null ? isNotNull(t) : ne(t, value); case 'contains': return likeCondition(target, `%${escapeLike(String(filter.value))}%`, dialect); case 'notContains': return likeCondition(target, `%${escapeLike(String(filter.value))}%`, dialect, { negate: true, }); case 'iContains': return likeCondition(target, `%${escapeLike(String(filter.value))}%`, dialect, { caseInsensitive: true, }); case 'startsWith': return likeCondition(target, `${escapeLike(String(filter.value))}%`, dialect); case 'endsWith': return likeCondition(target, `%${escapeLike(String(filter.value))}`, dialect); case 'gt': return gt(t, value); case 'gte': return gte(t, value); case 'lt': return lt(t, value); case 'lte': return lte(t, value); case 'between': { const [low, high] = value; return between(t, low, high); } case 'notBetween': { const [low, high] = value; return notBetween(t, low, high); } case 'in': case 'isAnyOf': return inArray(t, toArray(value)); case 'notIn': return notInArray(t, toArray(value)); case 'isEmpty': return isStringTarget(target) ? or(isNull(t), eq(t, '')) : isNull(t); case 'isNotEmpty': return isStringTarget(target) ? and(isNotNull(t), ne(t, '')) : isNotNull(t); case 'isNull': case 'notExists': return isNull(t); case 'isNotNull': case 'exists': return isNotNull(t); default: throw new Error(`Unsupported filter operator: ${String(operator)}`); } } function toArray(value) { return Array.isArray(value) ? value : [value]; } /** * Folds a list of `ColumnFilter`s (with arbitrarily nested `AND`/`OR`) into one * condition: top-level entries are ANDed; a node contributes * `base AND (...AND) AND (OR₁ OR OR₂ …)`, the same grouping the MikroORM * adapter emits. A pure group node (no `field`) contributes only its children. */ export function buildColumnFiltersCondition(filters, leaf) { return and(...filters.map((f) => buildNode(f, leaf, 0))); } function buildNode(filter, leaf, depth) { if (depth > MAX_FILTER_DEPTH) { throw new Error(`Filter nesting exceeds maximum depth (${MAX_FILTER_DEPTH}).`); } const isGroupNode = filter.field === undefined || filter.field === ''; const parts = isGroupNode ? [] : [leaf(filter)]; for (const sub of filter.AND ?? []) parts.push(buildNode(sub, leaf, depth + 1)); if (filter.OR && filter.OR.length > 0) { const branches = filter.OR.map((sub) => buildNode(sub, leaf, depth + 1)); // An OR branch that resolved to nothing must not widen the group to // "match everything" — drop the branch rather than the whole OR. const kept = branches.filter((b) => b !== undefined); if (kept.length > 0) parts.push(or(...kept)); } return and(...parts); } //# sourceMappingURL=operator-resolver.js.map