diff --git a/src/queries/sql/events/getWebsiteEvents.test.ts b/src/queries/sql/events/getWebsiteEvents.test.ts index 3ecf8bea2..940805545 100644 --- a/src/queries/sql/events/getWebsiteEvents.test.ts +++ b/src/queries/sql/events/getWebsiteEvents.test.ts @@ -32,6 +32,7 @@ async function loadModule({ const clickhouseRawQuery = vi .fn() .mockResolvedValueOnce([{ num: '25' }]) + .mockResolvedValueOnce([{ event_id: 'event-1', created_at: '2026-08-05 10:00:00' }]) .mockResolvedValueOnce([{ id: 'event-1' }]); const clickhouseParseFilters = vi.fn().mockReturnValue(clickhouseParseFiltersResult); @@ -116,29 +117,34 @@ describe('getWebsiteEvents', () => { } as any); const [countQuery] = clickhouseRawQuery.mock.calls[0]; - const [dataQuery] = clickhouseRawQuery.mock.calls[1]; + const [pagedEventsQuery] = clickhouseRawQuery.mock.calls[1]; + const [dataQuery, dataParams] = clickhouseRawQuery.mock.calls[2]; expect(countQuery).toContain('select count(*) as num from (select 1 from ('); expect(countQuery).not.toContain('event_data'); - expect(dataQuery).toContain('with candidate_events as ('); - expect(dataQuery).toContain('and url_path = {path:String}'); - expect(dataQuery).toContain('positionCaseInsensitive'); - expect(dataQuery).toContain( - 'inner join website_event on website_event.event_id = candidate_events.event_id', - ); + expect(pagedEventsQuery).toContain('select'); + expect(pagedEventsQuery).toContain('event_id,'); + expect(pagedEventsQuery).toContain('created_at'); + expect(pagedEventsQuery).toContain('from website_event'); + expect(pagedEventsQuery).toContain('and url_path = {path:String}'); + expect(pagedEventsQuery).toContain('positionCaseInsensitive'); + expect(pagedEventsQuery).toContain('order by created_at desc'); + expect(pagedEventsQuery).toContain('limit 20 offset 0'); expect(dataQuery).toContain('paged_event_data as ('); expect(dataQuery).toContain('where website_id = {websiteId:UUID}'); expect(dataQuery).toContain('from umami.event_data_pivot'); - expect(dataQuery).toContain('inner join paged_events on paged_events.id = event_data_pivot.event_id'); + expect(dataQuery).toContain('and event_id in {eventIds:Array(UUID)}'); expect(dataQuery).toContain( 'and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64}', ); - expect(dataQuery).toContain('from candidate_events'); - expect(dataQuery).toContain('from paged_events'); - expect(dataQuery).toContain('left join paged_event_data on paged_event_data.event_id = paged_events.id'); - expect(dataQuery).toContain('order by paged_events.createdAt desc'); + expect(dataQuery).toContain('from website_event'); + expect(dataQuery).toContain( + 'left join paged_event_data on paged_event_data.event_id = website_event.event_id', + ); + expect(dataQuery).toContain('order by indexOf({eventIds:Array(UUID)}, website_event.event_id)'); expect(dataQuery).toContain('toUInt8(1) as has_data'); expect(dataQuery).toContain('ifNull(paged_event_data.has_data, 0) as hasData'); + expect(dataParams).toMatchObject({ eventIds: ['event-1'] }); expect(result).toEqual({ data: [{ id: 'event-1' }], count: '25', @@ -156,6 +162,7 @@ describe('getWebsiteEvents', () => { const clickhouseRawQuery = vi .fn() .mockResolvedValueOnce([{ num: '25' }]) + .mockResolvedValueOnce([{ event_id: 'event-1', created_at: '2026-08-05 10:00:00' }]) .mockResolvedValueOnce([{ id: 'event-1' }]); vi.doMock('@/lib/db', () => ({ @@ -187,7 +194,7 @@ describe('getWebsiteEvents', () => { timezone: 'America/Los_Angeles', } as any); - const [dataQuery] = clickhouseRawQuery.mock.calls[1]; + const [dataQuery] = clickhouseRawQuery.mock.calls[2]; expect(dataQuery).toContain( 'and event_data_pivot.created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})', @@ -203,6 +210,7 @@ describe('getWebsiteEvents', () => { const clickhouseRawQuery = vi .fn() .mockResolvedValueOnce([{ num: '10000' }]) + .mockResolvedValueOnce([{ event_id: 'event-1', created_at: '2026-08-05 10:00:00' }]) .mockResolvedValueOnce([{ id: 'event-1' }]); vi.doMock('@/lib/db', () => ({ @@ -235,20 +243,65 @@ describe('getWebsiteEvents', () => { } as any); const [countQuery] = clickhouseRawQuery.mock.calls[0]; - const [dataQuery] = clickhouseRawQuery.mock.calls[1]; + const [pagedEventsQuery] = clickhouseRawQuery.mock.calls[1]; + const [dataQuery, dataParams] = clickhouseRawQuery.mock.calls[2]; expect(countQuery).toContain('select count(*) as num from (select 1 from ('); expect(countQuery).toContain('from website_event'); expect(countQuery).not.toContain('from website_event_stats_hourly'); - expect(dataQuery).toContain('with candidate_events as ('); - expect(dataQuery).toContain('select'); - expect(dataQuery).toContain('event_id,'); - expect(dataQuery).toContain('created_at'); + expect(pagedEventsQuery).toContain('select'); + expect(pagedEventsQuery).toContain('event_id,'); + expect(pagedEventsQuery).toContain('created_at'); + expect(pagedEventsQuery).toContain('from website_event'); + expect(pagedEventsQuery).toContain('limit 20 offset 0'); + expect(pagedEventsQuery).not.toContain('positionCaseInsensitive'); + expect(dataQuery).toContain('and event_id in {eventIds:Array(UUID)}'); expect(dataQuery).toContain('from website_event'); - expect(dataQuery).toContain('limit 20'); - expect(dataQuery).toContain( - 'inner join website_event on website_event.event_id = candidate_events.event_id', - ); - expect(dataQuery).not.toContain('positionCaseInsensitive'); + expect(dataQuery).toContain('order by indexOf({eventIds:Array(UUID)}, website_event.event_id)'); + expect(dataParams).toMatchObject({ eventIds: ['event-1'] }); + }); + + test('clickhouse returns an empty page without running the wide hydration query when no ids match', async () => { + vi.resetModules(); + + const clickhouseRawQuery = vi + .fn() + .mockResolvedValueOnce([{ num: '0' }]) + .mockResolvedValueOnce([]); + + vi.doMock('@/lib/db', () => ({ + CLICKHOUSE: 'clickhouse', + PRISMA: 'prisma', + runQuery: vi.fn((queries: Record unknown>) => queries.clickhouse()), + })); + + vi.doMock('@/lib/prisma', () => ({ + default: {}, + })); + + vi.doMock('@/lib/clickhouse', () => ({ + default: { + rawQuery: clickhouseRawQuery, + parseFilters: vi.fn().mockReturnValue(clickhouseParseFiltersResult), + }, + })); + + const { getWebsiteEvents } = await import('./getWebsiteEvents'); + + const result = await getWebsiteEvents('website-1', { + page: 1, + pageSize: 20, + } as any); + + expect(clickhouseRawQuery).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + data: [], + count: '0', + page: 1, + pageSize: 20, + orderBy: undefined, + search: undefined, + isCapped: false, + }); }); }); diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts index 05435ff8a..24d6de7ba 100644 --- a/src/queries/sql/events/getWebsiteEvents.ts +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -120,7 +120,6 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { ? `and ((positionCaseInsensitive(event_name, {search:String}) > 0 and event_type = ${EVENT_TYPE.customEvent}) or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = ${EVENT_TYPE.pageView}))` : ''; - const candidateLimit = maxResults ? Math.min(+maxResults, offset + size) : offset + size; const eventQuery = ` select @@ -140,57 +139,69 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) { : `select count(*) as num from (${eventQuery}) t`; const count = await rawQuery(countQuery, queryParams).then(res => res[0].num); + const pagedEventRows = await rawQuery<{ event_id: string; created_at: string }[]>( + ` + ${eventQuery} + order by created_at desc + limit ${size} offset ${offset} + `, + queryParams, + FUNCTION_NAME, + ); + + const eventIds = pagedEventRows.map(({ event_id }) => event_id); + + if (!eventIds.length) { + return { + data: [], + count, + page: +page, + pageSize: size, + orderBy, + search, + isCapped: !!maxResults && +count >= +maxResults, + }; + } const dataQuery = ` - with candidate_events as ( - ${eventQuery} - order by created_at desc - limit ${candidateLimit} - ), - paged_events as ( - select - website_event.event_id as id, - website_event.website_id as websiteId, - website_event.session_id as sessionId, - website_event.created_at as createdAt, - website_event.hostname, - website_event.url_path as urlPath, - website_event.url_query as urlQuery, - website_event.referrer_path as referrerPath, - website_event.referrer_query as referrerQuery, - website_event.referrer_domain as referrerDomain, - website_event.country, - website_event.city, - website_event.device, - website_event.os, - website_event.browser, - website_event.page_title as pageTitle, - website_event.event_type as eventType, - website_event.event_name as eventName - from candidate_events - inner join website_event on website_event.event_id = candidate_events.event_id - order by candidate_events.created_at desc - limit ${size} offset ${offset} - ), paged_event_data as ( select event_id, toUInt8(1) as has_data from umami.event_data_pivot - inner join paged_events on paged_events.id = event_data_pivot.event_id where website_id = {websiteId:UUID} + and event_id in {eventIds:Array(UUID)} ${hasDataDateQuery} group by event_id ) select - paged_events.*, + website_event.event_id as id, + website_event.website_id as websiteId, + website_event.session_id as sessionId, + website_event.created_at as createdAt, + website_event.hostname, + website_event.url_path as urlPath, + website_event.url_query as urlQuery, + website_event.referrer_path as referrerPath, + website_event.referrer_query as referrerQuery, + website_event.referrer_domain as referrerDomain, + website_event.country, + website_event.city, + website_event.device, + website_event.os, + website_event.browser, + website_event.page_title as pageTitle, + website_event.event_type as eventType, + website_event.event_name as eventName, ifNull(paged_event_data.has_data, 0) as hasData - from paged_events - left join paged_event_data on paged_event_data.event_id = paged_events.id - order by paged_events.createdAt desc + from website_event + left join paged_event_data on paged_event_data.event_id = website_event.event_id + where website_event.website_id = {websiteId:UUID} + and website_event.event_id in {eventIds:Array(UUID)} + order by indexOf({eventIds:Array(UUID)}, website_event.event_id) `; - const data = await rawQuery(dataQuery, queryParams, FUNCTION_NAME); + const data = await rawQuery(dataQuery, { ...queryParams, eventIds }, FUNCTION_NAME); return { data,