Skip to content

Commit b8fa898

Browse files
committed
feat: add wide table query support
1 parent 476553b commit b8fa898

File tree

7 files changed

+489
-31
lines changed

7 files changed

+489
-31
lines changed

src/server/model/insights/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import { insightsSurvey } from './survey.js';
88
import { insightsAIGateway } from './aiGateway.js';
99
import { compact, omit } from 'lodash-es';
1010
import { prisma } from '../_client.js';
11-
import { insightsWarehouse } from './warehouse/longTable.js';
11+
import { insightsLongTableWarehouse } from './warehouse/longTable.js';
1212
import { INIT_WORKSPACE_ID } from '../../utils/const.js';
13+
import { findWarehouseApplication } from './warehouse/utils.js';
14+
import { insightsWideTableWarehouse } from './warehouse/wideTable.js';
1315

1416
export function queryInsight(
1517
query: z.infer<typeof insightsQuerySchema>,
@@ -30,7 +32,12 @@ export function queryInsight(
3032
}
3133

3234
if (insightType === 'warehouse' && query.workspaceId === INIT_WORKSPACE_ID) {
33-
return insightsWarehouse(query, context);
35+
const application = findWarehouseApplication(query.insightId);
36+
if (application?.type === 'wideTable') {
37+
return insightsWideTableWarehouse(query, context);
38+
}
39+
40+
return insightsLongTableWarehouse(query, context);
3441
}
3542

3643
throw new Error('Unknown Insight Type');
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`WarehouseWideTableInsightsSqlBuilder > default > sql 1`] = `
4+
"select
5+
DATE_FORMAT("event_date", '%Y-%m-%d') date , count(1) as "$all_event"
6+
from "events"
7+
where "event_date" BETWEEN '2025-08-01' AND '2025-08-02'
8+
group by 1
9+
order by 1 desc
10+
limit 1000"
11+
`;
12+
13+
exports[`WarehouseWideTableInsightsSqlBuilder > group by > sql 1`] = `
14+
"select
15+
DATE_FORMAT("event_date", '%Y-%m-%d') date , is_public as "%is_public" , count(1) as "$all_event"
16+
from "events"
17+
where "event_date" BETWEEN '2025-08-01' AND '2025-08-08'
18+
group by 1 , 2
19+
order by 1 desc
20+
limit 1000"
21+
`;

src/server/model/insights/warehouse/longTable.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,16 @@ import {
1515
getWarehouseApplications,
1616
getWarehouseConnection,
1717
MYSQL_DATE_FORMATS,
18-
WarehouseInsightsApplication,
18+
WarehouseLongTableInsightsApplication,
1919
} from './utils.js';
2020

2121
export class WarehouseLongTableInsightsSqlBuilder extends InsightsSqlBuilder {
22-
getApplication(): WarehouseInsightsApplication {
22+
getApplication(): WarehouseLongTableInsightsApplication {
2323
const name = this.query.insightId;
2424

2525
const application = getWarehouseApplications().find(
26-
(app) => app.name === name
27-
);
26+
(app) => app.name === name && app.type === 'longTable'
27+
) as WarehouseLongTableInsightsApplication;
2828

2929
if (!application) {
3030
throw new Error(`Application ${name} not found`);
@@ -352,7 +352,8 @@ export class WarehouseLongTableInsightsSqlBuilder extends InsightsSqlBuilder {
352352
}
353353

354354
async executeQuery(sql: Prisma.Sql): Promise<any[]> {
355-
const connection = getWarehouseConnection();
355+
const application = this.getApplication();
356+
const connection = getWarehouseConnection(application.databaseUrl);
356357

357358
const [rows] = await connection.query(
358359
sql.sql.replaceAll('"', '`'), // avoid mysql and pg sql syntax error about double quote
@@ -363,7 +364,7 @@ export class WarehouseLongTableInsightsSqlBuilder extends InsightsSqlBuilder {
363364
}
364365
}
365366

366-
export async function insightsWarehouse(
367+
export async function insightsLongTableWarehouse(
367368
query: z.infer<typeof insightsQuerySchema>,
368369
context: { timezone: string }
369370
) {
@@ -377,18 +378,20 @@ export async function insightsWarehouse(
377378
return result;
378379
}
379380

380-
export async function insightsWarehouseEvents(
381+
export async function insightsLongTableWarehouseEvents(
381382
applicationId: string
382383
): Promise<string[]> {
383-
const connection = getWarehouseConnection();
384-
const eventTable = getWarehouseApplications().find(
385-
(app) => app.name === applicationId
386-
)?.eventTable;
384+
const application = getWarehouseApplications().find(
385+
(app) => app.name === applicationId && app.type === 'longTable'
386+
) as WarehouseLongTableInsightsApplication;
387+
const connection = getWarehouseConnection(application.databaseUrl);
387388

388-
if (!eventTable) {
389+
if (!application) {
389390
throw new Error(`Event table not found for application ${applicationId}`);
390391
}
391392

393+
const eventTable = application.eventTable;
394+
392395
const [rows] = await connection.query(`
393396
SELECT DISTINCT event_name
394397
FROM (
@@ -405,18 +408,20 @@ LIMIT 100;`);
405408
return compact(rows.map((row) => get(row, 'event_name', '')));
406409
}
407410

408-
export async function insightsWarehouseFilterParams(
411+
export async function insightsLongTableWarehouseFilterParams(
409412
applicationId: string
410413
): Promise<string[]> {
411-
const connection = getWarehouseConnection();
412-
const eventParametersTable = getWarehouseApplications().find(
413-
(app) => app.name === applicationId
414-
)?.eventParametersTable;
414+
const application = getWarehouseApplications().find(
415+
(app) => app.name === applicationId && app.type === 'longTable'
416+
) as WarehouseLongTableInsightsApplication;
417+
const connection = getWarehouseConnection(application.databaseUrl);
415418

416-
if (!eventParametersTable) {
419+
if (!application) {
417420
throw new Error(`Event table not found for application ${applicationId}`);
418421
}
419422

423+
const eventParametersTable = application.eventParametersTable;
424+
420425
const [rows] = await connection.query(`
421426
SELECT DISTINCT param_value
422427
FROM (

src/server/model/insights/warehouse/utils.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ export const dateTypeSchema = z.enum([
1818
'datetime', // for example: 2025-08-01 00:00:00
1919
]);
2020

21-
export const warehouseInsightsApplicationSchema = z.object({
21+
const warehouseLongTableInsightsApplicationSchema = z.object({
22+
databaseUrl: z.string().optional(),
2223
name: z.string(),
23-
type: z.enum(['longTable', 'wideTable']).default('longTable'), // long table or wide table, TODO: implement wide table support
24+
type: z.literal('longTable').optional(), // long table, default
2425
eventTable: z.object({
2526
name: z.string(),
2627
eventNameField: z.string(),
@@ -42,10 +43,40 @@ export const warehouseInsightsApplicationSchema = z.object({
4243
}),
4344
});
4445

46+
const warehouseWideTableInsightsApplicationSchema = z.object({
47+
databaseUrl: z.string().optional(),
48+
name: z.string(),
49+
type: z.literal('wideTable'), // wide table
50+
tableName: z.string(),
51+
fields: z.array(
52+
z.object({
53+
name: z.string(),
54+
type: z.string().default('string'),
55+
})
56+
),
57+
distinctField: z.string(),
58+
createdAtField: z.string(),
59+
createdAtFieldType: dateTypeSchema.default('timestampMs'),
60+
dateBasedCreatedAtField: z.string().optional(), // for improve performance, treat as date type
61+
});
62+
63+
export const warehouseInsightsApplicationSchema = z.union([
64+
warehouseLongTableInsightsApplicationSchema,
65+
warehouseWideTableInsightsApplicationSchema,
66+
]);
67+
4568
export type WarehouseInsightsApplication = z.infer<
4669
typeof warehouseInsightsApplicationSchema
4770
>;
4871

72+
export type WarehouseLongTableInsightsApplication = z.infer<
73+
typeof warehouseLongTableInsightsApplicationSchema
74+
>;
75+
76+
export type WarehouseWideTableInsightsApplication = z.infer<
77+
typeof warehouseWideTableInsightsApplicationSchema
78+
>;
79+
4980
let applications: WarehouseInsightsApplication[] | null = null;
5081
export function getWarehouseApplications() {
5182
if (!applications) {
@@ -57,14 +88,24 @@ export function getWarehouseApplications() {
5788
return applications;
5889
}
5990

60-
let connection: Pool | null = null;
61-
export function getWarehouseConnection() {
62-
if (!env.insights.warehouse.enable || !env.insights.warehouse.url) {
91+
export function findWarehouseApplication(name: string) {
92+
return getWarehouseApplications().find((app) => app.name === name);
93+
}
94+
95+
const connections = new Map<string, Pool>();
96+
export function getWarehouseConnection(url = env.insights.warehouse.url) {
97+
if (!env.insights.warehouse.enable) {
6398
throw new Error('Warehouse is not enabled');
6499
}
65100

101+
if (!url) {
102+
throw new Error('Warehouse url is not set');
103+
}
104+
105+
let connection = connections.get(url);
66106
if (!connection) {
67-
connection = createPool(env.insights.warehouse.url);
107+
connection = createPool(url);
108+
connections.set(url, connection);
68109
}
69110

70111
return connection;
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { beforeAll, describe, expect, test } from 'vitest';
2+
import { WarehouseWideTableInsightsSqlBuilder } from './wideTable.js';
3+
import { unwrapSQL } from '../../../utils/prisma.js';
4+
import dayjs from 'dayjs';
5+
import { env } from '../../../utils/env.js';
6+
7+
describe('WarehouseWideTableInsightsSqlBuilder', () => {
8+
const insightId = 'wide_table_test';
9+
const insightType = 'warehouse';
10+
11+
beforeAll(() => {
12+
env.insights.warehouse.applicationsJson = JSON.stringify([
13+
{
14+
name: 'wide_table_test',
15+
type: 'wideTable',
16+
tableName: 'events',
17+
fields: [
18+
{
19+
name: 'event_name',
20+
type: 'string',
21+
},
22+
],
23+
distinctField: 'user_id',
24+
createdAtField: 'event_timestamp',
25+
dateBasedCreatedAtField: 'event_date',
26+
},
27+
]);
28+
});
29+
30+
test('default', () => {
31+
const builder = new WarehouseWideTableInsightsSqlBuilder(
32+
{
33+
insightId,
34+
insightType,
35+
workspaceId: '',
36+
metrics: [
37+
{
38+
name: '$all_event',
39+
math: 'events',
40+
},
41+
],
42+
filters: [],
43+
time: {
44+
startAt: dayjs('2025-08-01').valueOf(),
45+
endAt: dayjs('2025-08-02').valueOf(),
46+
unit: 'day',
47+
},
48+
groups: [],
49+
},
50+
{
51+
timezone: 'UTC',
52+
}
53+
);
54+
55+
const sql = builder.build();
56+
expect(unwrapSQL(sql)).toMatchSnapshot('sql');
57+
});
58+
59+
test('group by', () => {
60+
const builder = new WarehouseWideTableInsightsSqlBuilder(
61+
{
62+
workspaceId: '',
63+
insightId: 'wide_table_test',
64+
insightType: 'warehouse',
65+
metrics: [
66+
{
67+
math: 'events',
68+
name: '$all_event',
69+
},
70+
],
71+
filters: [],
72+
groups: [
73+
{
74+
value: 'is_public',
75+
type: 'string',
76+
},
77+
],
78+
time: {
79+
startAt: 1753977600000,
80+
endAt: 1754668799999,
81+
unit: 'day',
82+
timezone: 'Asia/Shanghai',
83+
},
84+
},
85+
{
86+
timezone: 'UTC',
87+
}
88+
);
89+
90+
const sql = builder.build();
91+
expect(unwrapSQL(sql)).toMatchSnapshot('sql');
92+
});
93+
});

0 commit comments

Comments
 (0)