@dudousxd/nestjs-filter-clickhouse
Version:
ClickHouse adapter for @dudousxd/nestjs-filter: compiles structured filter input to parameterized ClickHouse SQL.
131 lines • 4.22 kB
JavaScript
const TABLE = Symbol.for('@dudousxd/nestjs-filter-clickhouse:table');
const SAFE_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
/** `Nullable(LowCardinality(String))` → `{ base: 'String', nullable: true }`. */
export function unwrapType(type) {
let t = type.trim();
let nullable = false;
for (;;) {
const m = /^(Nullable|LowCardinality)\((.*)\)$/s.exec(t);
if (!m)
break;
if (m[1] === 'Nullable')
nullable = true;
t = m[2].trim();
}
return { base: t, nullable };
}
export function typeKind(base) {
if (/^(String|FixedString\(\d+\)|UUID|Enum(8|16)?\(.*\)|IPv4|IPv6)$/s.test(base))
return 'string';
if (/^U?Int(8|16|32|64|128|256)$/.test(base))
return 'int';
if (/^(Float(32|64)|Decimal(32|64|128|256)?\(.*\)|BFloat16)$/.test(base))
return 'float';
if (base === 'Bool' || base === 'Boolean')
return 'bool';
if (base === 'Date' || base === 'Date32')
return 'date';
if (/^DateTime(64)?(\(.*\))?$/.test(base))
return 'datetime';
if (/^Array\(.*\)$/s.test(base))
return 'array';
return 'other';
}
function resolveField(name, def) {
if (!SAFE_NAME.test(name)) {
throw new Error(`ClickHouse field name "${name}" must be a plain identifier.`);
}
const spec = typeof def === 'string' ? { type: def } : def;
const { base, nullable } = unwrapType(spec.type);
const kind = typeKind(base);
let element;
if (kind === 'array') {
const inner = unwrapType(base.slice('Array('.length, -1));
element = { baseType: inner.base, kind: typeKind(inner.base) };
}
return {
name,
type: spec.type,
baseType: base,
kind,
...(element && { element }),
nullable,
expr: spec.expr ?? `\`${name}\``,
measure: spec.measure === true,
searchable: spec.searchable ?? kind === 'string',
};
}
/**
* A declared ClickHouse table — the adapter's "entity", used wherever a Drizzle table or an entity
* class goes: `@Filterable({ entity: events })`, `runner.findAndCount(events, input)`.
*/
export class ClickHouseTable {
[TABLE] = true;
table;
name;
primaryKey;
groupBy;
where;
fieldMap = new Map();
constructor(definition) {
this.table = definition.table;
this.name = definition.name ?? definition.table;
this.primaryKey = definition.primaryKey;
this.where = definition.where;
for (const [name, def] of Object.entries(definition.fields)) {
this.fieldMap.set(name, resolveField(name, def));
}
this.groupBy = definition.groupBy ?? [];
for (const dim of this.groupBy) {
const field = this.fieldMap.get(dim);
if (!field || field.measure) {
throw new Error(`ClickHouse table "${this.name}": groupBy "${dim}" is not a dimension field.`);
}
}
}
field(name) {
return this.fieldMap.get(name);
}
fields() {
return [...this.fieldMap.values()];
}
/** Whether the table has measures (rows are groups, measures aggregate per group). */
get aggregated() {
return this.fields().some((f) => f.measure);
}
}
/**
* Declares a ClickHouse table.
*
* ```ts
* export const events = defineClickHouseTable({
* table: 'events',
* primaryKey: 'id',
* fields: {
* id: 'UUID',
* at: "DateTime64(3, 'UTC')",
* event: { type: 'LowCardinality(String)', expr: 'name' },
* day: { type: 'Date', expr: 'toDate(at)' },
* durationMs: 'UInt64',
* },
* });
*
* export const errorsDaily = defineClickHouseTable({
* table: 'errors_daily',
* groupBy: ['event', 'provider'],
* fields: {
* day: 'Date',
* event: { type: 'String', expr: 'name' },
* provider: 'String',
* count: { type: 'UInt64', expr: 'sum(count)', measure: true },
* },
* });
* ```
*/
export function defineClickHouseTable(definition) {
return new ClickHouseTable(definition);
}
export function isClickHouseTable(value) {
return (value != null && typeof value === 'object' && value[TABLE] === true);
}
//# sourceMappingURL=table.js.map