UNPKG

@dudousxd/nestjs-filter-clickhouse

Version:

ClickHouse adapter for @dudousxd/nestjs-filter: compiles structured filter input to parameterized ClickHouse SQL.

257 lines • 10.5 kB
import { ClickHouseParams, andAll } from './sql.js'; /** * Output columns are aliased with this prefix in the generated SQL and un-prefixed on the way out. * ClickHouse resolves aliases inside WHERE and aggregate arguments, so `sum(errors) AS errors` or * `lower(provider) AS provider` would otherwise recurse into their own alias. */ export const ALIAS_PREFIX = '__'; const alias = (name) => `\`${ALIAS_PREFIX}${name}\``; /** Strips {@link ALIAS_PREFIX} from a result row's keys. */ export function unalias(row) { const out = {}; for (const [key, value] of Object.entries(row)) { out[key.startsWith(ALIAS_PREFIX) ? key.slice(ALIAS_PREFIX.length) : key] = value; } return out; } /** * The ClickHouse adapter's query builder: an accumulator of WHERE / HAVING conditions, ordering, a * page window and a projection over one {@link ClickHouseTable}, compiled to parameterized SQL. * * ```ts * const q = adapter.query(events); * await runner.apply(EventFilter, input, q); * q.toSQL(); // { query, params } — inspect or run with your own client * await q.executeAndCount(); // { rows, total } through the adapter's client * ``` */ export class ClickHouseQuery { table; client; /** Parameters shared by every fragment of this query (and its count query). */ params = new ClickHouseParams(); whereParts = []; havingParts = []; order = []; window = {}; distinctProjection = null; selection = null; computed = []; constructor(table, client) { this.table = table; this.client = client; } // ─── Building ────────────────────────────────────────────────────────────── /** * Adds a WHERE condition (ANDed). The text is trusted SQL: bind client values through * {@link bind} — `q.where(\`provider = ${q.bind('String', value)}\`)`. */ where(condition) { if (condition) this.whereParts.push(condition); return this; } /** Adds a HAVING condition (aggregated tables). Same trust rules as {@link where}. */ having(condition) { if (condition) this.havingParts.push(condition); return this; } /** Binds a value as a typed parameter of this query and returns its placeholder. */ bind(type, value) { return this.params.bind(type, value); } /** Adds a raw ORDER BY term (trusted SQL, e.g. `ts_col DESC`). */ orderBy(term) { this.order.push(term); return this; } /** * Orders by a declared field. Resolved when the SQL is compiled: a field the query projects * under an alias (a DISTINCT member, a group dimension or measure of an aggregated table) is * ordered by that alias, anything else by its expression. NULLs sort as on Postgres — last * ascending, first descending. */ orderByField(field, direction) { this.order.push({ field, direction }); return this; } clearOrderBy() { this.order.length = 0; return this; } limit(limit) { this.window = { ...this.window, limit }; return this; } offset(offset) { this.window = { ...this.window, offset }; return this; } /** `SELECT DISTINCT` of the given members (replaces the projection). */ distinct(members) { this.distinctProjection = [...members]; return this; } addDistinct(member) { this.distinctProjection = [...(this.distinctProjection ?? []), member]; return this; } /** * Narrows the projection to these fields. On an aggregated table the selected DIMENSIONS are the * GROUP BY, and selected measures narrow which measures are computed. */ select(fields) { this.selection = [...fields]; return this; } /** Adds a computed expression to the projection under `name`. */ addComputed(member) { this.computed.push(member); return this; } isDistinct() { return this.distinctProjection !== null; } getWhere() { return andAll([...(this.table.where ? [this.table.where] : []), ...this.whereParts]); } getHaving() { return andAll(this.havingParts); } // ─── Compilation ─────────────────────────────────────────────────────────── /** The GROUP BY dimensions of an aggregated table: selected dimensions, else the table's default. */ groupDimensions() { const selectedDims = (this.selection ?? []).filter((f) => { const field = this.table.field(f); return field && !field.measure; }); return selectedDims.length > 0 ? selectedDims : this.table.groupBy; } projection() { const fields = this.table.fields(); let members; if (this.table.aggregated) { const dims = this.groupDimensions(); const selectedMeasures = (this.selection ?? []).filter((f) => this.table.field(f)?.measure); const measures = fields.filter((f) => f.measure && (selectedMeasures.length === 0 || selectedMeasures.includes(f.name))); members = [ ...dims.map((d) => ({ name: d, expr: this.table.field(d).expr })), ...measures.map((m) => ({ name: m.name, expr: m.expr })), ]; } else if (this.selection) { members = this.selection.flatMap((name) => { const field = this.table.field(name); return field ? [{ name, expr: field.expr }] : []; }); } else { members = fields.map((f) => ({ name: f.name, expr: f.expr })); } return [...members, ...this.computed]; } fromWhere() { const where = this.getWhere(); return `FROM ${this.table.table}${where ? ` WHERE ${where}` : ''}`; } groupHaving() { if (!this.table.aggregated) return ''; const dims = this.groupDimensions(); const having = this.getHaving(); return `${dims.length > 0 ? ` GROUP BY ${dims.map((d) => this.table.field(d).expr).join(', ')}` : ''}${having ? ` HAVING ${having}` : ''}`; } windowSql() { const { limit, offset } = this.window; let out = ''; if (limit !== undefined) out += ` LIMIT ${Math.max(0, Math.trunc(limit))}`; if (offset !== undefined && offset > 0) { out += `${limit === undefined ? ' LIMIT 18446744073709551615' : ''} OFFSET ${Math.max(0, Math.trunc(offset))}`; } return out; } orderSql(projected) { if (this.order.length === 0) return ''; const names = new Set(projected.map((m) => m.name)); const terms = this.order.flatMap((term) => { if (typeof term === 'string') return [term]; const aliased = names.has(term.field) && (this.distinctProjection || this.table.aggregated); const field = this.table.field(term.field); const target = aliased ? alias(term.field) : field?.expr; if (!target) return []; return [ term.direction === 'desc' ? `${target} DESC NULLS FIRST` : `${target} ASC NULLS LAST`, ]; }); return terms.length > 0 ? ` ORDER BY ${terms.join(', ')}` : ''; } /** The page query. Every client value is in `params`; the text holds only placeholders. */ toSQL() { if (this.distinctProjection) { const cols = this.distinctProjection.map((m) => `${m.expr} AS ${alias(m.name)}`).join(', '); const order = this.orderSql(this.distinctProjection); return { query: `SELECT DISTINCT ${cols} ${this.fromWhere()}${order}${this.windowSql()}`, params: this.params.values, }; } const projected = this.projection(); const cols = projected.map((m) => `${m.expr} AS ${alias(m.name)}`).join(', '); const order = this.orderSql(projected); return { query: `SELECT ${cols} ${this.fromWhere()}${this.groupHaving()}${order}${this.windowSql()}`, params: this.params.values, }; } /** The total the page is a window of: rows, groups, or distinct tuples. */ toCountSQL() { let query; if (this.distinctProjection) { const cols = this.distinctProjection.map((m) => `${m.expr} AS ${alias(m.name)}`).join(', '); query = `SELECT count() AS total FROM (SELECT DISTINCT ${cols} ${this.fromWhere()})`; } else if (this.table.aggregated) { // The inner SELECT always aggregates (count()), so a table grouped by nothing counts as one // group rather than as its rows. query = `SELECT count() AS total FROM (SELECT count() ${this.fromWhere()}${this.groupHaving()})`; } else { query = `SELECT count() AS total ${this.fromWhere()}`; } return { query, params: this.params.values }; } // ─── Execution ───────────────────────────────────────────────────────────── requireClient() { if (!this.client) { throw new Error('ClickHouseQuery has no client: create it through a ClickHouseAdapter constructed with one, or run toSQL() yourself.'); } return this.client; } async run(statement) { const result = await this.requireClient().query({ query: statement.query, query_params: statement.params, format: 'JSONEachRow', }); return result.json(); } /** Runs the page query; rows are keyed by field name (the alias prefix is stripped). */ async execute() { const rows = await this.run(this.toSQL()); return rows.map(unalias); } async count() { const [row] = await this.run(this.toCountSQL()); return Number(row?.total ?? 0); } async executeAndCount() { const [rows, total] = await Promise.all([this.execute(), this.count()]); return { rows, total }; } } //# sourceMappingURL=clickhouse-query.js.map