476 lines
14 KiB
TypeScript
476 lines
14 KiB
TypeScript
import { type ClickHouseClient, createClient } from '@clickhouse/client';
|
|
import { formatInTimeZone } from 'date-fns-tz';
|
|
import debug from 'debug';
|
|
import { CLICKHOUSE } from '@/lib/db';
|
|
import { DATA_TYPE, DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants';
|
|
import { filtersObjectToArray } from './params';
|
|
import type { Operator, PropertyFilter, QueryFilters, QueryOptions } from './types';
|
|
|
|
export const CLICKHOUSE_DATE_FORMATS = {
|
|
utc: '%Y-%m-%dT%H:%i:%SZ',
|
|
second: '%Y-%m-%dT%H:%i:%S',
|
|
minute: '%Y-%m-%d %H:%i:00',
|
|
hour: '%Y-%m-%d %H:00:00',
|
|
day: '%Y-%m-%d',
|
|
month: '%Y-%m-01',
|
|
year: '%Y-01-01',
|
|
};
|
|
|
|
const log = debug('umami:clickhouse');
|
|
|
|
const EQUALITY_OPERATORS: Operator[] = [OPERATORS.equals, OPERATORS.notEquals];
|
|
const REGEX_OPERATORS: Operator[] = [OPERATORS.regex, OPERATORS.notRegex];
|
|
|
|
let clickhouse: ClickHouseClient;
|
|
const enabled = Boolean(process.env.CLICKHOUSE_URL);
|
|
|
|
function getClient() {
|
|
const clickhouseUrl = process.env.CLICKHOUSE_URL;
|
|
|
|
if (!clickhouseUrl) {
|
|
throw new Error('CLICKHOUSE_URL is not set.');
|
|
}
|
|
|
|
const {
|
|
hostname,
|
|
port,
|
|
pathname,
|
|
protocol,
|
|
username = 'default',
|
|
password,
|
|
} = new URL(clickhouseUrl);
|
|
|
|
const client = createClient({
|
|
url: `${protocol}//${hostname}:${port}`,
|
|
database: pathname.replace('/', ''),
|
|
username: username,
|
|
password,
|
|
});
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
globalThis[CLICKHOUSE] = client;
|
|
}
|
|
|
|
log('Clickhouse initialized');
|
|
|
|
return client;
|
|
}
|
|
|
|
function getUTCString(date?: Date | string | number) {
|
|
return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
|
|
}
|
|
|
|
function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
|
|
if (timezone) {
|
|
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
|
|
}
|
|
|
|
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
|
|
}
|
|
|
|
function getDateSQL(field: string, unit: string, timezone?: string) {
|
|
if (timezone) {
|
|
return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'), '${timezone}')`;
|
|
}
|
|
return `toDateTime(date_trunc('${unit}', ${field}))`;
|
|
}
|
|
|
|
function getSearchSQL(column: string, param: string = 'search'): string {
|
|
return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`;
|
|
}
|
|
|
|
function mapFilter(
|
|
column: string,
|
|
operator: Operator,
|
|
name: string,
|
|
type: string = 'String',
|
|
paramName?: string,
|
|
) {
|
|
const param = paramName ?? name;
|
|
const value = `{${param}:${type}}`;
|
|
|
|
switch (operator) {
|
|
case OPERATORS.equals:
|
|
return `${column} IN {${param}:Array(${type})}`;
|
|
case OPERATORS.notEquals:
|
|
return `${column} NOT IN {${param}:Array(${type})}`;
|
|
case OPERATORS.contains:
|
|
return `positionCaseInsensitive(${column}, ${value}) > 0`;
|
|
case OPERATORS.doesNotContain:
|
|
return `positionCaseInsensitive(${column}, ${value}) = 0`;
|
|
case OPERATORS.regex:
|
|
return `match(${column}, concat('(?i)', ${value}))`;
|
|
case OPERATORS.notRegex:
|
|
return `not match(${column}, concat('(?i)', ${value}))`;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) {
|
|
const { isCohort, cohortMatch, cohortActionName } = options;
|
|
const isOr = isCohort ? cohortMatch === 'any' : filters.match === 'any';
|
|
const orClauses: string[] = [];
|
|
const andClauses: string[] = [];
|
|
|
|
filtersObjectToArray(filters, options).forEach(({ name, column, operator, paramName }) => {
|
|
if (isCohort) {
|
|
column = FILTER_COLUMNS[name.slice('cohort_'.length)];
|
|
}
|
|
|
|
if (column) {
|
|
const isAlwaysAnd = name === 'eventType' || (isCohort && name === cohortActionName);
|
|
|
|
if (isAlwaysAnd) {
|
|
andClauses.push(
|
|
`and ${mapFilter(column, operator, name, name === 'eventType' ? 'UInt32' : 'String', paramName)}`,
|
|
);
|
|
} else if (isOr) {
|
|
orClauses.push(mapFilter(column, operator, name, 'String', paramName));
|
|
} else {
|
|
andClauses.push(`and ${mapFilter(column, operator, name, 'String', paramName)}`);
|
|
}
|
|
|
|
if (name === 'referrer') {
|
|
andClauses.push(`and referrer_domain != hostname`);
|
|
}
|
|
}
|
|
});
|
|
|
|
const parts: string[] = [];
|
|
|
|
if (orClauses.length > 0) {
|
|
parts.push(`and (\n ${orClauses.join('\n or ')}\n)`);
|
|
}
|
|
|
|
parts.push(...andClauses);
|
|
|
|
return parts.join('\n');
|
|
}
|
|
|
|
function getCohortQuery(filters: Record<string, any>) {
|
|
if (!filters || Object.keys(filters).length === 0) {
|
|
return '';
|
|
}
|
|
|
|
const cohortMatch = filters.cohort_match;
|
|
const cohortActionName = filters.cohort_actionName;
|
|
|
|
const filterQuery = getFilterQuery(filters, { isCohort: true, cohortMatch, cohortActionName });
|
|
|
|
return `join (
|
|
select distinct session_id as cohort_session_id
|
|
from website_event
|
|
where website_id = {websiteId:UUID}
|
|
and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64}
|
|
${filterQuery}
|
|
) as cohort
|
|
on cohort.cohort_session_id = website_event.session_id
|
|
`;
|
|
}
|
|
|
|
function getExcludeBounceQuery(filters: Record<string, any>) {
|
|
if (filters.excludeBounce !== true) {
|
|
return '';
|
|
}
|
|
|
|
return `join
|
|
(select distinct session_id as exclude_session_id, visit_id as exclude_visit_id
|
|
from website_event_stats_hourly
|
|
where website_id = {websiteId:UUID}
|
|
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
|
and event_type = 1
|
|
group by session_id, visit_id
|
|
having sum(views) > 1
|
|
) excludeBounce
|
|
on excludeBounce.exclude_session_id = website_event.session_id
|
|
and excludeBounce.exclude_visit_id = website_event.visit_id
|
|
`;
|
|
}
|
|
|
|
function getDateQuery(filters: Record<string, any>) {
|
|
const { startDate, endDate, timezone } = filters;
|
|
|
|
if (startDate) {
|
|
if (endDate) {
|
|
if (timezone) {
|
|
return `and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})`;
|
|
}
|
|
return `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`;
|
|
} else {
|
|
if (timezone) {
|
|
return `and created_at >= toTimezone({startDate:DateTime64},{timezone:String})`;
|
|
}
|
|
return `and created_at >= {startDate:DateTime64}`;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function getQueryParams(filters: Record<string, any>) {
|
|
return {
|
|
...filters,
|
|
...filtersObjectToArray(filters).reduce((obj, { name, column, operator, value, paramName }) => {
|
|
const resolvedColumn =
|
|
column || (name?.startsWith('cohort_') && FILTER_COLUMNS[name.slice('cohort_'.length)]);
|
|
|
|
if (!resolvedColumn || !name || value === undefined) return obj;
|
|
|
|
const key = paramName ?? name;
|
|
|
|
obj[key] = EQUALITY_OPERATORS.includes(operator)
|
|
? Array.isArray(value)
|
|
? value
|
|
: [value]
|
|
: value;
|
|
|
|
return obj;
|
|
}, {}),
|
|
};
|
|
}
|
|
|
|
function parseFilters(filters: Record<string, any>, options?: QueryOptions) {
|
|
const cohortFilters = Object.fromEntries(
|
|
Object.entries(filters).filter(([key]) => key.startsWith('cohort_')),
|
|
);
|
|
|
|
return {
|
|
filterQuery: getFilterQuery(filters, options),
|
|
dateQuery: getDateQuery(filters),
|
|
queryParams: getQueryParams(filters),
|
|
cohortQuery: getCohortQuery(cohortFilters),
|
|
excludeBounceQuery: getExcludeBounceQuery(filters),
|
|
};
|
|
}
|
|
|
|
function getPropertyFilterQuery(
|
|
filters: PropertyFilter[] = [],
|
|
propertyType: 'event' | 'session' = 'event',
|
|
timezone?: string,
|
|
): {
|
|
sql: string;
|
|
params: Record<string, any>;
|
|
} {
|
|
if (!filters.length) return { sql: '', params: {} };
|
|
|
|
const parts: string[] = [];
|
|
const params: Record<string, any> = {};
|
|
const table = propertyType === 'event' ? 'event_data' : 'session_data final';
|
|
const column = propertyType === 'event' ? 'event_id' : 'session_id';
|
|
const outerColumn = propertyType === 'event' ? 'event_id' : 'website_event.session_id';
|
|
const dateFilter =
|
|
propertyType === 'event'
|
|
? `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`
|
|
: '';
|
|
|
|
filters.forEach(({ propertyName, dataType, operator, value }, i) => {
|
|
const keyParam = `pf_key_${i}`;
|
|
const valParam = `pf_val_${i}`;
|
|
params[keyParam] = propertyName;
|
|
|
|
let condition: string;
|
|
switch (dataType) {
|
|
case DATA_TYPE.number: {
|
|
const col = 'number_value';
|
|
params[valParam] = parseFloat(value) || 0;
|
|
const opMap: Record<string, string> = {
|
|
[OPERATORS.equals]: `${col} = {${valParam}:Float64}`,
|
|
[OPERATORS.notEquals]: `${col} != {${valParam}:Float64}`,
|
|
[OPERATORS.greaterThan]: `${col} > {${valParam}:Float64}`,
|
|
[OPERATORS.lessThan]: `${col} < {${valParam}:Float64}`,
|
|
[OPERATORS.greaterThanEquals]: `${col} >= {${valParam}:Float64}`,
|
|
[OPERATORS.lessThanEquals]: `${col} <= {${valParam}:Float64}`,
|
|
};
|
|
condition = opMap[operator] ?? `${col} = {${valParam}:Float64}`;
|
|
break;
|
|
}
|
|
case DATA_TYPE.date: {
|
|
if (!value) return;
|
|
params[valParam] = value;
|
|
const dateCol = timezone
|
|
? `toDate(toTimezone(date_value, {timezone:String}))`
|
|
: `toDate(toTimezone(date_value, 'UTC'))`;
|
|
const opMap: Record<string, string> = {
|
|
[OPERATORS.before]: `${dateCol} < {${valParam}:Date}`,
|
|
[OPERATORS.after]: `${dateCol} > {${valParam}:Date}`,
|
|
};
|
|
condition = opMap[operator] ?? `${dateCol} = {${valParam}:Date}`;
|
|
break;
|
|
}
|
|
case DATA_TYPE.array: {
|
|
if (!value) return;
|
|
params[valParam] = value;
|
|
condition =
|
|
operator === OPERATORS.contains
|
|
? `has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})`
|
|
: `not has(JSONExtract(ifNull(string_value, '[]'), 'Array(String)'), {${valParam}:String})`;
|
|
break;
|
|
}
|
|
default: {
|
|
const col = 'string_value';
|
|
|
|
if (EQUALITY_OPERATORS.includes(operator)) {
|
|
const vals = value.split(',').filter(Boolean);
|
|
if (!vals.length) return;
|
|
params[valParam] = vals;
|
|
condition = mapFilter(
|
|
col,
|
|
operator === OPERATORS.equals ? OPERATORS.equals : OPERATORS.notEquals,
|
|
valParam,
|
|
'String',
|
|
);
|
|
} else if (REGEX_OPERATORS.includes(operator)) {
|
|
if (!value) return;
|
|
params[valParam] = value;
|
|
condition = mapFilter(
|
|
col,
|
|
operator === OPERATORS.regex ? OPERATORS.regex : OPERATORS.notRegex,
|
|
valParam,
|
|
'String',
|
|
);
|
|
} else {
|
|
if (!value) return;
|
|
params[valParam] = value;
|
|
condition = mapFilter(
|
|
col,
|
|
operator === OPERATORS.contains ? OPERATORS.contains : OPERATORS.doesNotContain,
|
|
valParam,
|
|
'String',
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (propertyType === 'session') {
|
|
parts.push(`and tuple(website_event.website_id, website_event.session_id) in (
|
|
select website_id, session_id
|
|
from ${table}
|
|
where website_id = {websiteId:UUID}
|
|
and data_key = {${keyParam}:String}
|
|
and data_type = ${dataType}
|
|
and ${condition}
|
|
)`);
|
|
} else {
|
|
parts.push(`and ${outerColumn} in (
|
|
select ${column}
|
|
from ${table}
|
|
where website_id = {websiteId:UUID}
|
|
${dateFilter}
|
|
and data_key = {${keyParam}:String}
|
|
and data_type = ${dataType}
|
|
and ${condition}
|
|
)`);
|
|
}
|
|
});
|
|
|
|
return { sql: parts.join('\n'), params };
|
|
}
|
|
|
|
async function pagedRawQuery(
|
|
query: string,
|
|
queryParams: Record<string, any>,
|
|
filters: QueryFilters,
|
|
name?: string,
|
|
) {
|
|
const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters;
|
|
const size = +pageSize || DEFAULT_PAGE_SIZE;
|
|
const offset = +size * (+page - 1);
|
|
const direction = sortDescending ? 'desc' : 'asc';
|
|
|
|
const statements = [
|
|
orderBy && `order by ${orderBy} ${direction}`,
|
|
+size > 0 && `limit ${+size} offset ${+offset}`,
|
|
]
|
|
.filter(n => n)
|
|
.join('\n');
|
|
|
|
const { maxResults } = filters;
|
|
const countQuery = maxResults
|
|
? `select count(*) as num from (select 1 from (${query}) t limit ${+maxResults}) t2`
|
|
: `select count(*) as num from (${query}) t`;
|
|
|
|
const count = await rawQuery(countQuery, queryParams).then(res => res[0].num);
|
|
const data = await rawQuery(`${query}${statements}`, queryParams, name);
|
|
|
|
return {
|
|
data,
|
|
count,
|
|
page: +page,
|
|
pageSize: size,
|
|
orderBy,
|
|
search,
|
|
isCapped: !!maxResults && +count >= +maxResults,
|
|
};
|
|
}
|
|
|
|
async function rawQuery<T = unknown>(
|
|
query: string,
|
|
params: Record<string, unknown> = {},
|
|
name?: string,
|
|
): Promise<T> {
|
|
if (process.env.LOG_QUERY) {
|
|
log({ query, params, name });
|
|
}
|
|
|
|
await connect();
|
|
|
|
const resultSet = await clickhouse.query({
|
|
query: query,
|
|
query_params: params,
|
|
format: 'JSONEachRow',
|
|
clickhouse_settings: {
|
|
date_time_output_format: 'iso',
|
|
output_format_json_quote_64bit_integers: 0,
|
|
},
|
|
});
|
|
|
|
return (await resultSet.json()) as T;
|
|
}
|
|
|
|
async function insert(table: string, values: any[]) {
|
|
await connect();
|
|
|
|
return clickhouse.insert({ table, values, format: 'JSONEachRow' });
|
|
}
|
|
|
|
async function findUnique(data: any[]) {
|
|
if (data.length > 1) {
|
|
throw `${data.length} records found when expecting 1.`;
|
|
}
|
|
|
|
return findFirst(data);
|
|
}
|
|
|
|
async function findFirst(data: any[]) {
|
|
return data[0] ?? null;
|
|
}
|
|
|
|
async function connect() {
|
|
if (enabled && !clickhouse) {
|
|
clickhouse = globalThis[CLICKHOUSE] || getClient();
|
|
}
|
|
|
|
return clickhouse;
|
|
}
|
|
|
|
export default {
|
|
enabled,
|
|
client: clickhouse,
|
|
log,
|
|
connect,
|
|
getDateStringSQL,
|
|
getDateSQL,
|
|
getSearchSQL,
|
|
getFilterQuery,
|
|
getPropertyFilterQuery,
|
|
getUTCString,
|
|
parseFilters,
|
|
pagedRawQuery,
|
|
findUnique,
|
|
findFirst,
|
|
rawQuery,
|
|
insert,
|
|
};
|