@dudousxd/nestjs-filter-drizzle
Version:
Drizzle ORM adapter for @dudousxd/nestjs-filter.
192 lines • 8.51 kB
JavaScript
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