@dudousxd/nestjs-filter-clickhouse
Version:
ClickHouse adapter for @dudousxd/nestjs-filter: compiles structured filter input to parameterized ClickHouse SQL.
290 lines • 11.7 kB
JavaScript
import { MAX_FILTER_DEPTH, normalizeOperator } from '@dudousxd/nestjs-filter';
import { BadRequestException } from '@nestjs/common';
/** A client value the adapter cannot bind to the field's type (e.g. `age=abc` on a UInt64). */
export class ClickHouseValueError extends BadRequestException {
constructor(message) {
super(`Invalid filter value: ${message}`);
}
}
/**
* Collects typed query parameters. Every client value is bound as `{name:Type}` and sent in
* `query_params` — nothing client-supplied is ever part of the SQL text.
*/
export class ClickHouseParams {
prefix;
values = {};
n = 0;
constructor(prefix = 'p') {
this.prefix = prefix;
}
/** Binds `value` as a parameter of `type` and returns its placeholder. */
bind(type, value) {
const name = `${this.prefix}${this.n++}`;
this.values[name] = value;
return `{${name}:${type}}`;
}
}
const pad = (n, width = 2) => String(n).padStart(width, '0');
/** A Date as ClickHouse's `YYYY-MM-DD hh:mm:ss.sss` (UTC). */
function dateTimeText(date) {
return (`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}.${pad(date.getUTCMilliseconds(), 3)}`);
}
function toDate(value) {
if (value instanceof Date)
return Number.isNaN(value.getTime()) ? null : value;
if (typeof value === 'string' || typeof value === 'number') {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
return null;
}
/**
* Encodes a client value for a parameter of the given kind. Query strings deliver text, so text is
* parsed; a value that does not parse is rejected with a 400 rather than handed to ClickHouse,
* which would fail the whole query on the parameter.
*/
export function encodeValue(kind, value, field) {
switch (kind) {
case 'int':
case 'float': {
if (typeof value === 'bigint')
return value.toString();
const text = typeof value === 'string' ? value.trim() : value;
const n = typeof text === 'number' ? text : text === '' ? Number.NaN : Number(text);
if (!Number.isFinite(n) || (kind === 'int' && !Number.isInteger(n))) {
throw new ClickHouseValueError(`"${field}" expects ${kind === 'int' ? 'an integer' : 'a number'}.`);
}
// Integers beyond 2^53 keep their exact digits as text (ClickHouse parses the parameter).
return typeof value === 'string' && kind === 'int' ? value.trim() : n;
}
case 'bool':
if (value === true || value === 'true' || value === '1' || value === 1)
return true;
if (value === false || value === 'false' || value === '0' || value === 0)
return false;
throw new ClickHouseValueError(`"${field}" expects a boolean.`);
case 'date': {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
return value;
const d = toDate(value);
if (!d)
throw new ClickHouseValueError(`"${field}" expects a date (YYYY-MM-DD).`);
return dateTimeText(d).slice(0, 10);
}
case 'datetime': {
const d = toDate(value);
if (!d)
throw new ClickHouseValueError(`"${field}" expects a date-time.`);
return dateTimeText(d);
}
default:
if (value !== null && typeof value === 'object') {
throw new ClickHouseValueError(`"${field}" expects a scalar.`);
}
return String(value);
}
}
export function targetOf(field) {
return {
expr: field.expr,
paramType: field.baseType,
kind: field.kind,
nullable: field.nullable,
field: field.name,
};
}
function asList(value) {
return Array.isArray(value) ? value : [value];
}
/** The expression as text for LIKE-style operators (a cast for non-strings, like the SQL adapters). */
function textExpr(t) {
return t.kind === 'string' ? t.expr : `toString(${t.expr})`;
}
/**
* Compiles ONE operator over a scalar target into a ClickHouse boolean expression, binding every
* value. Semantics follow the SQL adapters (Postgres as the reference):
*
* - comparisons with NULL are NULL, so negated operators do not match NULL values — including
* `notIn`, which ClickHouse would otherwise answer `1` for a NULL (`transform_null_in = 0`);
* - `contains`/`startsWith`/`endsWith` are case-sensitive, `iContains` is not (UTF-8 aware);
* values are matched literally (`position`, not `LIKE`, so `%`/`_` are not wildcards);
* - `isEmpty` is NULL-or-`''` for strings and NULL elsewhere.
*/
export function compileOperator(t, filter, p) {
const operator = normalizeOperator(filter.operator);
const e = t.expr;
const one = (v) => p.bind(t.paramType, encodeValue(t.kind, v, t.field));
const text = () => p.bind('String', String(filter.value));
const value = filter.value;
switch (operator) {
case 'equals':
return value === null ? `isNull(${e})` : `${e} = ${one(value)}`;
case 'notEquals':
return value === null ? `isNotNull(${e})` : `${e} != ${one(value)}`;
case 'contains':
return `position(${textExpr(t)}, ${text()}) > 0`;
case 'notContains':
return `position(${textExpr(t)}, ${text()}) = 0`;
case 'iContains':
return `positionCaseInsensitiveUTF8(${textExpr(t)}, ${text()}) > 0`;
case 'startsWith':
return `startsWith(${textExpr(t)}, ${text()})`;
case 'endsWith':
return `endsWith(${textExpr(t)}, ${text()})`;
case 'gt':
return `${e} > ${one(value)}`;
case 'gte':
return `${e} >= ${one(value)}`;
case 'lt':
return `${e} < ${one(value)}`;
case 'lte':
return `${e} <= ${one(value)}`;
case 'between':
case 'notBetween': {
const [low, high] = asList(value);
const inside = `${e} BETWEEN ${one(low)} AND ${one(high)}`;
return operator === 'between' ? inside : `NOT (${inside})`;
}
case 'in':
case 'isAnyOf': {
const members = asList(value).filter((v) => v !== null && v !== undefined);
if (members.length === 0)
return '0';
const encoded = members.map((v) => encodeValue(t.kind, v, t.field));
return `${e} IN ${p.bind(`Array(${t.paramType})`, encoded)}`;
}
case 'notIn': {
const list = asList(value);
if (list.length === 0)
return '1';
// `x NOT IN (…, NULL)` is never TRUE in SQL.
if (list.some((v) => v === null || v === undefined))
return '0';
const encoded = list.map((v) => encodeValue(t.kind, v, t.field));
const notIn = `${e} NOT IN ${p.bind(`Array(${t.paramType})`, encoded)}`;
return t.nullable ? `(isNotNull(${e}) AND ${notIn})` : notIn;
}
case 'isEmpty':
if (t.kind === 'string')
return t.nullable ? `(isNull(${e}) OR ${e} = '')` : `${e} = ''`;
return `isNull(${e})`;
case 'isNotEmpty':
if (t.kind === 'string')
return t.nullable ? `(isNotNull(${e}) AND ${e} != '')` : `${e} != ''`;
return `isNotNull(${e})`;
case 'isNull':
case 'notExists':
return `isNull(${e})`;
case 'isNotNull':
case 'exists':
return `isNotNull(${e})`;
default:
throw new Error(`Unsupported filter operator: ${String(operator)}`);
}
}
/** Operators that hold for an array when NO element matches their positive form. */
const NEGATED = {
notEquals: 'equals',
notContains: 'contains',
notIn: 'in',
notBetween: 'between',
};
/**
* Compiles an operator over a field, including `Array(T)` fields: positive operators hold when ANY
* element matches (`arrayExists(x -> …, arr)`), negated ones when NO element does; `isEmpty` is
* NULL-or-`[]`. Same semantics as the memory adapter's array fields.
*/
export function compileFieldOperator(field, filter, p) {
if (field.kind !== 'array' || !field.element)
return compileOperator(targetOf(field), filter, p);
const operator = normalizeOperator(filter.operator);
const e = field.expr;
switch (operator) {
case 'isEmpty':
return field.nullable ? `(isNull(${e}) OR empty(${e}))` : `empty(${e})`;
case 'isNotEmpty':
return field.nullable ? `(isNotNull(${e}) AND notEmpty(${e}))` : `notEmpty(${e})`;
case 'isNull':
case 'notExists':
return `isNull(${e})`;
case 'isNotNull':
case 'exists':
return `isNotNull(${e})`;
}
const element = {
expr: '__x',
paramType: field.element.baseType,
kind: field.element.kind,
nullable: false,
field: field.name,
};
const positive = Object.hasOwn(NEGATED, operator) ? NEGATED[operator] : undefined;
if (positive) {
const inner = compileOperator(element, { ...filter, operator: positive }, p);
return `NOT arrayExists(__x -> ${inner}, ${e})`;
}
return `arrayExists(__x -> ${compileOperator(element, { ...filter, operator }, p)}, ${e})`;
}
/**
* Folds `ColumnFilter`s with nested `AND`/`OR` into one expression, with the grouping the other
* adapters use: top-level entries ANDed; a node is `leaf AND (…AND) AND (OR₁ OR OR₂ …)`; a pure
* group node contributes only its children; an unresolvable OR branch is dropped rather than
* widening the group. `leaf` returns `undefined` for a field it cannot resolve.
*/
export function compileColumnFilters(filters, leaf) {
const parts = filters
.map((f) => compileNode(f, leaf, 0))
.filter((s) => s !== undefined);
return andAll(parts);
}
function compileNode(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 = [];
if (!isGroupNode) {
const own = leaf(filter);
if (own !== undefined)
parts.push(own);
}
for (const sub of filter.AND ?? []) {
const s = compileNode(sub, leaf, depth + 1);
if (s !== undefined)
parts.push(s);
}
if (filter.OR && filter.OR.length > 0) {
const branches = filter.OR.map((sub) => compileNode(sub, leaf, depth + 1)).filter((s) => s !== undefined);
if (branches.length === 1)
parts.push(branches[0]);
else if (branches.length > 1)
parts.push(`(${branches.join(' OR ')})`);
}
return andAll(parts);
}
/** `(a AND b)`, `a`, or `undefined` for nothing. */
export function andAll(parts) {
if (parts.length === 0)
return undefined;
if (parts.length === 1)
return parts[0];
return `(${parts.join(' AND ')})`;
}
/** Every field a filter tree references. */
export function referencedFields(filters) {
const out = new Set();
const walk = (fs) => {
for (const f of fs) {
if (f.field)
out.add(f.field);
if (f.AND)
walk(f.AND);
if (f.OR)
walk(f.OR);
}
};
walk(filters);
return out;
}
//# sourceMappingURL=sql.js.map