@dudousxd/nestjs-filter-clickhouse
Version:
ClickHouse adapter for @dudousxd/nestjs-filter: compiles structured filter input to parameterized ClickHouse SQL.
257 lines • 10.5 kB
JavaScript
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