fix group issues

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-03-16 23:33:05 +01:00
parent 995f32c5d8
commit 2dc622cbf2
5 changed files with 63 additions and 50 deletions

View File

@@ -20,11 +20,17 @@ export const average = (arr: (number | null)[], includeZero = false) => {
export const sum = (arr: (number | null | undefined)[]): number => export const sum = (arr: (number | null | undefined)[]): number =>
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0)); round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
export const min = (arr: (number | null | undefined)[]): number => export const min = (arr: (number | null | undefined)[]): number => {
Math.min(...arr.filter(isNumber)); const filtered = arr.filter(isNumber);
if (filtered.length === 0) return 0;
return filtered.reduce((a, b) => (b < a ? b : a), filtered[0]!);
};
export const max = (arr: (number | null | undefined)[]): number => export const max = (arr: (number | null | undefined)[]): number => {
Math.max(...arr.filter(isNumber)); const filtered = arr.filter(isNumber);
if (filtered.length === 0) return 0;
return filtered.reduce((a, b) => (b > a ? b : a), filtered[0]!);
};
export const isFloat = (n: number) => n % 1 !== 0; export const isFloat = (n: number) => n % 1 !== 0;

View File

@@ -26,7 +26,7 @@ export function format(
}>, }>,
includeAlphaIds: boolean, includeAlphaIds: boolean,
previousSeries: ConcreteSeries[] | null = null, previousSeries: ConcreteSeries[] | null = null,
limit: number | undefined = undefined, limit: number | undefined = undefined
): FinalChart { ): FinalChart {
const series = concreteSeries.map((cs) => { const series = concreteSeries.map((cs) => {
// Find definition for this series // Find definition for this series
@@ -70,7 +70,7 @@ export function format(
const previousSerie = previousSeries?.find( const previousSerie = previousSeries?.find(
(ps) => (ps) =>
ps.definitionIndex === cs.definitionIndex && ps.definitionIndex === cs.definitionIndex &&
ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::'), ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::')
); );
return { return {
@@ -89,24 +89,24 @@ export function format(
previous: { previous: {
sum: getPreviousMetric( sum: getPreviousMetric(
metrics.sum, metrics.sum,
sum(previousSerie.data.map((d) => d.count)), sum(previousSerie.data.map((d) => d.count))
), ),
average: getPreviousMetric( average: getPreviousMetric(
metrics.average, metrics.average,
round(average(previousSerie.data.map((d) => d.count)), 2), round(average(previousSerie.data.map((d) => d.count)), 2)
), ),
min: getPreviousMetric( min: getPreviousMetric(
metrics.min, metrics.min,
min(previousSerie.data.map((d) => d.count)), min(previousSerie.data.map((d) => d.count))
), ),
max: getPreviousMetric( max: getPreviousMetric(
metrics.max, metrics.max,
max(previousSerie.data.map((d) => d.count)), max(previousSerie.data.map((d) => d.count))
), ),
count: getPreviousMetric( count: getPreviousMetric(
metrics.count ?? 0, metrics.count ?? 0,
previousSerie.data.find((item) => !!item.total_count) previousSerie.data.find((item) => !!item.total_count)
?.total_count ?? null, ?.total_count ?? null
), ),
}, },
} }
@@ -118,7 +118,7 @@ export function format(
previous: previousSerie?.data[index] previous: previousSerie?.data[index]
? getPreviousMetric( ? getPreviousMetric(
item.count, item.count,
previousSerie.data[index]?.count ?? null, previousSerie.data[index]?.count ?? null
) )
: undefined, : undefined,
})), })),

View File

@@ -150,7 +150,7 @@ export function getChartSql({
if (event.name !== '*') { if (event.name !== '*') {
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`; sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`; sb.where.eventName = `e.name = ${sqlstring.escape(event.name)}`;
} else { } else {
sb.select.label_0 = `'*' as label_0`; sb.select.label_0 = `'*' as label_0`;
} }
@@ -359,7 +359,7 @@ export function getChartSql({
if (event.segment === 'one_event_per_user') { if (event.segment === 'one_event_per_user') {
sb.from = `( sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} e ${getJoins()} WHERE ${join(
sb.where, sb.where,
' AND ' ' AND '
)} )}
@@ -380,40 +380,47 @@ export function getChartSql({
: ''; : '';
if (breakdowns.length > 0) { if (breakdowns.length > 0) {
// Match breakdown properties in subquery with outer query's grouped values // Pre-compute unique counts per breakdown group in a CTE, then JOIN it.
// Since outer query groups by label_X, we reference those in the correlation // We can't use a correlated subquery because:
const breakdownMatches = breakdowns // 1. ClickHouse expands label_X aliases to their underlying expressions,
// which resolve in the subquery's scope, making the condition a tautology.
// 2. Correlated subqueries aren't supported on distributed/remote tables.
const ucSelectParts: string[] = breakdowns.map((breakdown, index) => {
const propertyKey = getSelectPropertyKey(breakdown.name, projectId);
return `${propertyKey} as _uc_label_${index + 1}`;
});
ucSelectParts.push('uniq(profile_id) as total_count');
const ucGroupByParts = breakdowns.map(
(_, index) => `_uc_label_${index + 1}`
);
const ucWhere = getWhereWithoutBar();
addCte(
'_uc',
`SELECT ${ucSelectParts.join(', ')} FROM ${TABLE_NAMES.events} e ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${ucWhere} GROUP BY ${ucGroupByParts.join(', ')}`
);
const ucJoinConditions = breakdowns
.map((b, index) => { .map((b, index) => {
const propertyKey = getSelectPropertyKey(b.name, projectId); const propertyKey = getSelectPropertyKey(b.name, projectId);
// Correlate: match the property expression with outer query's label_X value return `_uc._uc_label_${index + 1} = ${propertyKey}`;
// ClickHouse allows referencing outer query columns in correlated subqueries
return `${propertyKey} = label_${index + 1}`;
}) })
.join(' AND '); .join(' AND ');
// Build WHERE clause for subquery - replace table alias and keep profile CTE reference sb.joins.unique_counts = `LEFT ANY JOIN _uc ON ${ucJoinConditions}`;
const subqueryWhere = getWhereWithoutBar() sb.select.total_unique_count = 'any(_uc.total_count) as total_count';
.replace(/\be\./g, 'e2.')
.replace(/\bprofile\./g, 'profile.');
sb.select.total_unique_count = `(
SELECT uniq(profile_id)
FROM ${TABLE_NAMES.events} e2
${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere}
AND ${breakdownMatches}
) as total_count`;
} else { } else {
// No breakdowns: calculate unique count across all data const ucWhere = getWhereWithoutBar();
// Build WHERE clause for subquery - replace table alias and keep profile CTE reference
const subqueryWhere = getWhereWithoutBar()
.replace(/\be\./g, 'e2.')
.replace(/\bprofile\./g, 'profile.');
sb.select.total_unique_count = `( addCte(
SELECT uniq(profile_id) '_uc',
FROM ${TABLE_NAMES.events} e2 `SELECT uniq(profile_id) as total_count FROM ${TABLE_NAMES.events} e ${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${ucWhere}`
${subqueryGroupJoins}${profilesJoinRef ? `${profilesJoinRef} ` : ''}${subqueryWhere} );
) as total_count`;
sb.select.total_unique_count =
'(SELECT total_count FROM _uc) as total_count';
} }
const sql = `${getWith()}${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`; const sql = `${getWith()}${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
@@ -440,7 +447,7 @@ export function getAggregateChartSql({
if (event.name !== '*') { if (event.name !== '*') {
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`; sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`; sb.where.eventName = `e.name = ${sqlstring.escape(event.name)}`;
} else { } else {
sb.select.label_0 = `'*' as label_0`; sb.select.label_0 = `'*' as label_0`;
} }
@@ -617,7 +624,7 @@ export function getAggregateChartSql({
if (event.segment === 'one_event_per_user') { if (event.segment === 'one_event_per_user') {
sb.from = `( sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} e ${getJoins()} WHERE ${join(
sb.where, sb.where,
' AND ' ' AND '
)} )}

View File

@@ -101,11 +101,11 @@ export class ConversionService {
// Build funnel conditions // Build funnel conditions
const conditionA = whereA const conditionA = whereA
? `(name = '${eventA.name}' AND ${whereA})` ? `(events.name = '${eventA.name}' AND ${whereA})`
: `name = '${eventA.name}'`; : `events.name = '${eventA.name}'`;
const conditionB = whereB const conditionB = whereB
? `(name = '${eventB.name}' AND ${whereB})` ? `(events.name = '${eventB.name}' AND ${whereB})`
: `name = '${eventB.name}'`; : `events.name = '${eventB.name}'`;
const groupJoin = needsGroupArrayJoin const groupJoin = needsGroupArrayJoin
? `ARRAY JOIN groups AS _group_id LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id` ? `ARRAY JOIN groups AS _group_id LEFT ANY JOIN (SELECT id, name, type, properties FROM ${TABLE_NAMES.groups} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) AS _g ON _g.id = _group_id`
@@ -141,7 +141,7 @@ export class ConversionService {
${profileJoin} ${profileJoin}
${groupJoin} ${groupJoin}
WHERE project_id = '${projectId}' WHERE project_id = '${projectId}'
AND name IN ('${eventA.name}', '${eventB.name}') AND events.name IN ('${eventA.name}', '${eventB.name}')
AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}') AND created_at BETWEEN toDateTime('${startDate}') AND toDateTime('${endDate}')
GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''}) GROUP BY ${group}${breakdownExpressions.length ? `, ${breakdownExpressions.join(', ')}` : ''})
`), `),

View File

@@ -38,7 +38,7 @@ export class FunnelService {
return events.map((event) => { return events.map((event) => {
const { sb, getWhere } = createSqlBuilder(); const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters, projectId); sb.where = getEventFiltersWhereClause(event.filters, projectId);
sb.where.name = `name = ${sqlstring.escape(event.name)}`; sb.where.name = `events.name = ${sqlstring.escape(event.name)}`;
return getWhere().replace('WHERE ', ''); return getWhere().replace('WHERE ', '');
}); });
} }
@@ -90,7 +90,7 @@ export class FunnelService {
clix.datetime(endDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'),
]) ])
.where( .where(
'name', 'events.name',
'IN', 'IN',
eventSeries.map((e) => e.name), eventSeries.map((e) => e.name),
) )