@dudousxd/nestjs-filter-clickhouse
v0.1.1
Published
ClickHouse adapter for @dudousxd/nestjs-filter: compiles structured filter input to parameterized ClickHouse SQL.
Maintainers
Readme
@dudousxd/nestjs-filter-clickhouse
ClickHouse adapter for @dudousxd/nestjs-filter: compiles the structured
input — filter / where operators, sort, paginate, search, distinct, select, cursor
pages, groupByCount, extent — to one parameterized ClickHouse statement. Every client value
is bound as a typed {pN:Type} query parameter; client-supplied field names only select among
declared expressions. Nothing a client sends is ever part of the SQL text.
pnpm add @dudousxd/nestjs-filter @dudousxd/nestjs-filter-clickhouse @clickhouse/clientDeclare a table
The table is the adapter's "entity". Its fields are the allowlist: a trusted SQL expression per
field plus its ClickHouse type (which types every bound parameter).
import { defineClickHouseTable } from '@dudousxd/nestjs-filter-clickhouse';
export const events = defineClickHouseTable({
table: 'events',
primaryKey: 'id',
where: 'at >= now64(3) - toIntervalDay(90)', // optional always-on guard (trusted SQL)
fields: {
id: 'UUID',
at: "DateTime64(3, 'UTC')",
day: { type: 'Date', expr: 'toDate(at)' },
event: { type: 'LowCardinality(String)', expr: 'name' },
provider: 'Nullable(String)',
durationMs: 'UInt64',
tags: 'Array(String)',
},
});Aggregated tables (measures)
Fields marked measure are aggregate expressions. A table with measures answers with groups:
rows are grouped by the requested dimensions (select, else the table's groupBy), a filter that
touches a measure goes to HAVING, sorting by a measure orders groups, and the total counts groups.
export const chatDaily = defineClickHouseTable({
table: 'chat_daily',
groupBy: ['provider', 'model'],
fields: {
day: 'Date',
provider: 'LowCardinality(String)',
model: 'LowCardinality(String)',
turns: { type: 'UInt64', expr: 'sum(turns)', measure: true },
errorRate: { type: 'Float64', expr: 'sum(errors) / nullIf(sum(turns), 0)', measure: true },
},
});
// GET /stats/chat?select=day&filter[provider]=openai&filter[where][0][field]=turns&filter[where][0][operator]=>=&filter[where][0][value]=100&sort=-turnsRegister and query
import { createClient } from '@clickhouse/client';
import { clickHouseAdapter, ClickHouseFilter, type ClickHouseQuery } from '@dudousxd/nestjs-filter-clickhouse';
FilterModule.forRoot({ adapter: clickHouseAdapter({ client: createClient({ url }) }) });
// or { connection: CLICKHOUSE } — the token of a provider holding the client
@Injectable()
@Filterable({ entity: events, defaultSort: '-at' })
export class EventFilter extends ClickHouseFilter {
static readonly search = ['event', 'provider'];
@FilterFor('days')
lastDays(value: string) {
this.$query.where(`at >= now64(3) - toIntervalDay(${this.$query.bind('UInt32', Number(value))})`);
}
}
@Get()
list(@ApplyFilter(EventFilter) q: ClickHouseQuery) {
return q.executeAndCount(); // { rows, total }
}q.toSQL() / q.toCountSQL() return { query, params } to run with any client. Next to a
database adapter, provide ClickHouseAdapter under CLICKHOUSE_FILTER_ADAPTER and name it per
filter: @Filterable({ entity: events, adapter: CLICKHOUSE_FILTER_ADAPTER }).
Semantics
The same answers as the SQL and memory adapters (Postgres as the reference):
- NULL logic — negated operators do not match NULL values. ClickHouse answers
NULL NOT IN (…)with1(transform_null_in = 0), sonotInon aNullablefield addsisNotNull(…). - Case & wildcards —
contains/startsWith/endsWithare case-sensitive,iContainsandsearchcase-insensitive (UTF-8). Matching usesposition()/startsWith(), notLIKE:%and_in values are literal. Non-string fields are matched viatoString(). - Coercion — values are encoded for the field type (
'30'→ UInt,'true'→ Bool, ISO strings →DateTime64text in UTC); a value that does not fit is a 400 (ClickHouseValueError), not a failed query. isEmpty— NULL-or-''on strings, NULL-or-[]on arrays, NULL elsewhere.- Array fields — positive operators hold when any element matches (
arrayExists), negated ones when none does. - Ordering —
NULLS LASTascending,NULLS FIRSTdescending. - Output columns are aliased
__<field>in the SQL (ClickHouse resolves aliases inside WHERE and aggregates, sosum(errors) AS errorswould recurse) and un-prefixed on execution. - Computed fields — SQL expressions (strings, or functions returning a string).
- Not supported: relations / includes / to-many aggregates (ClickHouse has no correlated
subqueries to build them on),
tsvectorsearch.
Result values are whatever JSONEachRow returns: 64-bit integers arrive as strings unless you set
output_format_json_quote_64bit_integers: 0 on the client, and dates as text.
Testing
pnpm test runs the SQL-snapshot suite. With a server, the real-server suite runs the
cross-adapter contract's operator expectations against ClickHouse:
docker run -d --name ch -p 8123:8123 -e CLICKHOUSE_USER=test -e CLICKHOUSE_PASSWORD=test clickhouse/clickhouse-server:25.8
CLICKHOUSE_URL=http://test:test@localhost:8123 pnpm --filter @dudousxd/nestjs-filter-clickhouse test