diff --git a/packages/db/src/services/chart-sql.test.ts b/packages/db/src/services/chart-sql.test.ts index f9c997a7e..95542d51e 100644 --- a/packages/db/src/services/chart-sql.test.ts +++ b/packages/db/src/services/chart-sql.test.ts @@ -13,7 +13,7 @@ */ import type { IChartBreakdown, IChartEvent } from '@openpanel/validation'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { ch } from '../clickhouse/client'; +import { ch, formatClickhouseDate } from '../clickhouse/client'; import { getAggregateChartSql as _getAggregateChartSql, getChartSql as _getChartSql, @@ -713,3 +713,39 @@ describe('chart.service / single-pass total_count', () => { await explain(sql); }); }); + +describe('chart.service / profile event window', () => { + for (const [name, build] of [ + ['timeseries', getChartSql], + ['aggregate', getAggregateChartSql], + ] as const) { + it(`${name} restricts profile reads to the same project, event and dates`, async () => { + const sql = await build({ + event: event(), + breakdowns: [breakdown('profile.properties.plan')], + interval: 'day', + startDate: '2026-09-01 00:00:00', + endDate: '2026-09-02 00:00:00', + projectId: PROJECT_ID, + timezone: 'UTC', + }); + expect(sql).toContain(`id IN (SELECT profile_id FROM events WHERE project_id = '${PROJECT_ID}' AND created_at >= toDateTime('${formatClickhouseDate('2026-09-01 00:00:00')}') AND created_at <= toDateTime('${formatClickhouseDate('2026-09-02 00:00:00')}') AND name = ('screen_view'))`); + expect(sql).toContain('FROM profiles FINAL'); + if (chReachable) await explain(sql); + }); + + it(`${name} keeps all event names for wildcard series`, async () => { + const sql = await build({ + event: event({ name: '*' }), + breakdowns: [breakdown('profile.properties.plan')], + interval: 'day', + startDate: '', + endDate: '', + projectId: PROJECT_ID, + timezone: 'UTC', + }); + expect(sql).toContain(`id IN (SELECT profile_id FROM events WHERE project_id = '${PROJECT_ID}')`); + expect(sql).not.toContain("name = '*'"); + }); + } +}); diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 3e121c11e..b2ccd4020 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -9,7 +9,8 @@ import { type IReportInput, } from '@openpanel/validation'; import sqlstring from 'sqlstring'; -import { formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client'; +import { ch, formatClickhouseDate, TABLE_NAMES } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; import { db } from '../prisma-client'; import { createSqlBuilder } from '../sql-builder'; import { buildTypedClause, hasTypedCast, isTypedOperator } from './filter-cast'; @@ -462,6 +463,30 @@ export function rewriteProfilePropertyRefs(sql: string, keys: string[]): string return out; } +function profileEventWindow({ + projectId, + startDate, + endDate, + event, +}: Pick) { + const query = clix(ch) + .select(['profile_id']) + .from(TABLE_NAMES.events) + .where('project_id', '=', projectId); + if (startDate) { + query.where('created_at', '>=', clix.datetime(startDate, 'toDateTime')); + } + if (endDate) { + query.where('created_at', '<=', clix.datetime(endDate, 'toDateTime')); + } + if (event.name !== '*') { + // Event names must remain string literals, even when they look like dates. + query.where('name', '=', clix.exp(sqlstring.escape(event.name))); + } + // Filter by the join key before FINAL without excluding profiles updated outside the event window. + return `id IN (${query.toSQL()})`; +} + export async function getChartSql({ event, breakdowns: initialBreakdowns, @@ -691,7 +716,8 @@ export async function getChartSql({ 'profile', `SELECT ${selectFields.join(', ')} FROM ${TABLE_NAMES.profiles} FINAL - WHERE project_id = ${sqlstring.escape(projectId)}` + WHERE project_id = ${sqlstring.escape(projectId)} + AND ${profileEventWindow({ projectId, startDate, endDate, event })}` ); // Use the CTE reference in the main query @@ -1073,7 +1099,8 @@ export async function getAggregateChartSql({ 'profile', `SELECT ${selectFields.join(', ')} FROM ${TABLE_NAMES.profiles} FINAL - WHERE project_id = ${sqlstring.escape(projectId)}` + WHERE project_id = ${sqlstring.escape(projectId)} + AND ${profileEventWindow({ projectId, startDate, endDate, event })}` ); sb.joins.profiles = profilesJoinRef;