diff --git a/.changeset/plenty-pumas-taste.md b/.changeset/plenty-pumas-taste.md new file mode 100644 index 000000000..4caa6329f --- /dev/null +++ b/.changeset/plenty-pumas-taste.md @@ -0,0 +1,7 @@ +--- +"@hyperdx/common-utils": patch +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +feat: queryChartConfig method + events chart ratio diff --git a/packages/api/src/tasks/checkAlerts.ts b/packages/api/src/tasks/checkAlerts.ts index 40adcaff8..385eda856 100644 --- a/packages/api/src/tasks/checkAlerts.ts +++ b/packages/api/src/tasks/checkAlerts.ts @@ -780,6 +780,7 @@ export const processAlert = async (now: Date, alert: EnhancedAlert) => { select: firstTile.config.select, timestampValueExpression: source.timestampValueExpression, where: firstTile.config.where, + seriesReturnType: firstTile.config.seriesReturnType, }; } } @@ -821,14 +822,10 @@ export const processAlert = async (now: Date, alert: EnhancedAlert) => { password: connection.password, }); const metadata = getMetadata(clickhouseClient); - const query = await renderChartConfig(chartConfig, metadata); - const checksData = await clickhouseClient - .query<'JSON'>({ - query: query.sql, - query_params: query.params, - format: 'JSON', - }) - .then(res => res.json>()); + const checksData = await clickhouseClient.queryChartConfig({ + config: chartConfig, + metadata, + }); logger.info({ message: `Received alert metric [${alert.source} source]`, @@ -846,7 +843,7 @@ export const processAlert = async (now: Date, alert: EnhancedAlert) => { state: alertState, }).save(); - if (checksData?.rows && checksData?.rows > 0) { + if (checksData?.data && checksData?.data.length > 0) { // attach JS type const meta = checksData.meta?.map(m => ({ diff --git a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx b/packages/app/src/hooks/__tests__/useChartConfig.test.tsx deleted file mode 100644 index 74eb0abd8..000000000 --- a/packages/app/src/hooks/__tests__/useChartConfig.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { ResponseJSON } from '@clickhouse/client-web'; - -import { computeRatio, computeResultSetRatio } from '../useChartConfig'; - -describe('computeRatio', () => { - it('should correctly compute ratio of two numbers', () => { - expect(computeRatio('10', '2')).toBe(5); - expect(computeRatio('3', '4')).toBe(0.75); - expect(computeRatio('0', '5')).toBe(0); - }); - - it('should return NaN when denominator is zero', () => { - expect(isNaN(computeRatio('10', '0'))).toBe(true); - }); - - it('should return NaN for non-numeric inputs', () => { - expect(isNaN(computeRatio('abc', '2'))).toBe(true); - expect(isNaN(computeRatio('10', 'xyz'))).toBe(true); - expect(isNaN(computeRatio('abc', 'xyz'))).toBe(true); - expect(isNaN(computeRatio('', '5'))).toBe(true); - }); - - it('should handle string representations of numbers', () => { - expect(computeRatio('10.5', '2')).toBe(5.25); - expect(computeRatio('-10', '5')).toBe(-2); - expect(computeRatio('10', '-5')).toBe(-2); - }); - - it('should handle number input types', () => { - expect(computeRatio(10, 2)).toBe(5); - expect(computeRatio(3, 4)).toBe(0.75); - expect(computeRatio(10.5, 2)).toBe(5.25); - expect(computeRatio(0, 5)).toBe(0); - expect(isNaN(computeRatio(10, 0))).toBe(true); - expect(computeRatio(-10, 5)).toBe(-2); - }); - - it('should handle mixed string and number inputs', () => { - expect(computeRatio('10', 2)).toBe(5); - expect(computeRatio(10, '2')).toBe(5); - expect(computeRatio(3, '4')).toBe(0.75); - expect(isNaN(computeRatio(10, ''))).toBe(true); - }); -}); - -describe('computeResultSetRatio', () => { - it('should compute ratio for a valid result set with timestamp column', () => { - const mockResultSet: ResponseJSON = { - meta: [ - { name: 'timestamp', type: 'DateTime' }, - { name: 'requests', type: 'UInt64' }, - { name: 'errors', type: 'UInt64' }, - ], - data: [ - { timestamp: '2025-04-15 10:00:00', requests: '100', errors: '10' }, - { timestamp: '2025-04-15 11:00:00', requests: '200', errors: '20' }, - ], - rows: 2, - statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 }, - }; - - const result = computeResultSetRatio(mockResultSet); - - expect(result.meta.length).toBe(2); - expect(result.meta[0].name).toBe('requests/errors'); - expect(result.meta[0].type).toBe('Float64'); - expect(result.meta[1].name).toBe('timestamp'); - - expect(result.data.length).toBe(2); - expect(result.data[0]['requests/errors']).toBe(10); - expect(result.data[0].timestamp).toBe('2025-04-15 10:00:00'); - expect(result.data[1]['requests/errors']).toBe(10); - expect(result.data[1].timestamp).toBe('2025-04-15 11:00:00'); - }); - - it('should compute ratio for a valid result set without timestamp column', () => { - const mockResultSet: ResponseJSON = { - meta: [ - { name: 'requests', type: 'UInt64' }, - { name: 'errors', type: 'UInt64' }, - ], - data: [{ requests: '100', errors: '10' }], - rows: 1, - statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 }, - }; - - const result = computeResultSetRatio(mockResultSet); - - expect(result.meta.length).toBe(1); - expect(result.meta[0].name).toBe('requests/errors'); - expect(result.meta[0].type).toBe('Float64'); - - expect(result.data.length).toBe(1); - expect(result.data[0]['requests/errors']).toBe(10); - expect(result.data[0].timestamp).toBeUndefined(); - }); - - it('should handle NaN values in ratio computation', () => { - const mockResultSet: ResponseJSON = { - meta: [ - { name: 'timestamp', type: 'DateTime' }, - { name: 'requests', type: 'UInt64' }, - { name: 'errors', type: 'UInt64' }, - ], - data: [ - { timestamp: '2025-04-15 10:00:00', requests: '100', errors: '0' }, - { timestamp: '2025-04-15 11:00:00', requests: 'invalid', errors: '20' }, - ], - rows: 2, - statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 }, - }; - - const result = computeResultSetRatio(mockResultSet); - - expect(result.data.length).toBe(2); - expect(isNaN(result.data[0]['requests/errors'])).toBe(true); - expect(isNaN(result.data[1]['requests/errors'])).toBe(true); - }); - - it('should throw error when result set has insufficient columns', () => { - const mockResultSet: ResponseJSON = { - meta: [ - { name: 'timestamp', type: 'DateTime' }, - { name: 'requests', type: 'UInt64' }, - ], - data: [{ timestamp: '2025-04-15 10:00:00', requests: '100' }], - rows: 1, - statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 }, - }; - - expect(() => computeResultSetRatio(mockResultSet)).toThrow( - /Unable to compute ratio/, - ); - }); -}); diff --git a/packages/app/src/hooks/useChartConfig.tsx b/packages/app/src/hooks/useChartConfig.tsx index e1a7f294e..9982fa1a8 100644 --- a/packages/app/src/hooks/useChartConfig.tsx +++ b/packages/app/src/hooks/useChartConfig.tsx @@ -19,107 +19,6 @@ import { IS_MTVIEWS_ENABLED } from '@/config'; import { buildMTViewSelectQuery } from '@/hdxMTViews'; import { getMetadata } from '@/metadata'; -export const isMetric = (config: ChartConfigWithOptDateRange) => - config.metricTables != null; - -// TODO: apply this to all chart configs -export const setChartSelectsAlias = (config: ChartConfigWithOptDateRange) => { - if (Array.isArray(config.select) && isMetric(config)) { - return { - ...config, - select: config.select.map(s => ({ - ...s, - alias: `${s.aggFn}(${s.metricName})`, - })), - }; - } - return config; -}; - -export const splitChartConfigs = (config: ChartConfigWithOptDateRange) => { - // only split metric queries for now - if (isMetric(config) && Array.isArray(config.select)) { - const _configs = []; - // split the query into multiple queries - for (const select of config.select) { - _configs.push({ - ...config, - select: [select], - }); - } - return _configs; - } - return [config]; -}; - -const castToNumber = (value: string | number) => { - if (typeof value === 'string') { - if (value.trim() === '') { - return NaN; - } - return Number(value); - } - return value; -}; - -export const computeRatio = ( - numeratorInput: string | number, - denominatorInput: string | number, -) => { - const numerator = castToNumber(numeratorInput); - const denominator = castToNumber(denominatorInput); - - if (isNaN(numerator) || isNaN(denominator) || denominator === 0) { - return NaN; - } - - return numerator / denominator; -}; - -export const computeResultSetRatio = (resultSet: ResponseJSON) => { - const _meta = resultSet.meta; - const _data = resultSet.data; - const timestampColumn = inferTimestampColumn(_meta ?? []); - const _restColumns = _meta?.filter(m => m.name !== timestampColumn?.name); - const firstColumn = _restColumns?.[0]; - const secondColumn = _restColumns?.[1]; - if (!firstColumn || !secondColumn) { - throw new Error( - `Unable to compute ratio - meta information: ${JSON.stringify(_meta)}.`, - ); - } - const ratioColumnName = `${firstColumn.name}/${secondColumn.name}`; - const result = { - ...resultSet, - data: _data.map(row => ({ - [ratioColumnName]: computeRatio( - row[firstColumn.name], - row[secondColumn.name], - ), - ...(timestampColumn - ? { - [timestampColumn.name]: row[timestampColumn.name], - } - : {}), - })), - meta: [ - { - name: ratioColumnName, - type: 'Float64', - }, - ...(timestampColumn - ? [ - { - name: timestampColumn.name, - type: timestampColumn.type, - }, - ] - : []), - ], - }; - return result; -}; - interface AdditionalUseQueriedChartConfigOptions { onError?: (error: Error | ClickHouseQueryError) => void; } @@ -134,8 +33,6 @@ export function useQueriedChartConfig( const query = useQuery, ClickHouseQueryError | Error>({ queryKey: [config], queryFn: async ({ signal }) => { - config = setChartSelectsAlias(config); - let query = null; if (IS_MTVIEWS_ENABLED) { const { dataTableDDL, mtViewDDL, renderMTViewConfig } = @@ -148,86 +45,13 @@ export function useQueriedChartConfig( query = await renderMTViewConfig(); } - // TODO: move multi-series logics to common-utils so alerting can use it - const queries: ChSql[] = await Promise.all( - splitChartConfigs(config).map(c => renderChartConfig(c, getMetadata())), - ); - - const isTimeSeries = config.displayType === 'line'; - - const resultSets = await Promise.all( - queries.map(async query => { - const resp = await clickhouseClient.query<'JSON'>({ - query: query.sql, - query_params: query.params, - format: 'JSON', - abort_signal: signal, - connectionId: config.connection, - }); - return resp.json(); - }), - ); - - if (resultSets.length === 1) { - const isRatio = - config.seriesReturnType === 'ratio' && - resultSets[0].meta?.length === 3; - return isRatio ? computeResultSetRatio(resultSets[0]) : resultSets[0]; - } - // metrics -> join resultSets - else if (resultSets.length > 1) { - const metaSet = new Map(); - const tsBucketMap = new Map>(); - for (const resultSet of resultSets) { - // set up the meta data - if (Array.isArray(resultSet.meta)) { - for (const meta of resultSet.meta) { - const key = meta.name; - if (!metaSet.has(key)) { - metaSet.set(key, meta); - } - } - } - - const timestampColumn = inferTimestampColumn(resultSet.meta ?? []); - const numericColumn = inferNumericColumn(resultSet.meta ?? []); - const numericColumnName = numericColumn?.[0]?.name; - for (const row of resultSet.data) { - const _rowWithoutValue = numericColumnName - ? Object.fromEntries( - Object.entries(row).filter( - ([key]) => key !== numericColumnName, - ), - ) - : { ...row }; - const ts = - timestampColumn != null - ? row[timestampColumn.name] - : isTimeSeries - ? objectHash(_rowWithoutValue) - : '__FIXED_TIMESTAMP__'; - if (tsBucketMap.has(ts)) { - const existingRow = tsBucketMap.get(ts); - tsBucketMap.set(ts, { - ...existingRow, - ...row, - }); - } else { - tsBucketMap.set(ts, row); - } - } - } - - const isRatio = - config.seriesReturnType === 'ratio' && resultSets.length === 2; - - const _resultSet = { - meta: Array.from(metaSet.values()), - data: Array.from(tsBucketMap.values()), - }; - return isRatio ? computeResultSetRatio(_resultSet) : _resultSet; - } - throw new Error('No result sets'); + return clickhouseClient.queryChartConfig({ + config, + metadata: getMetadata(), + opts: { + abort_signal: signal, + }, + }); }, retry: 1, refetchOnWindowFocus: false, diff --git a/packages/common-utils/src/__tests__/clickhouse.test.ts b/packages/common-utils/src/__tests__/clickhouse.test.ts index 7caa4b1ad..7873e9f7e 100644 --- a/packages/common-utils/src/__tests__/clickhouse.test.ts +++ b/packages/common-utils/src/__tests__/clickhouse.test.ts @@ -1,6 +1,10 @@ +import { ResponseJSON } from '@clickhouse/client-common'; + import { ChSql, chSqlToAliasMap, + computeRatio, + computeResultSetRatio, convertCHDataTypeToJSType, JSDataType, } from '@/clickhouse'; @@ -197,3 +201,135 @@ describe('chSqlToAliasMap - alias unit test', () => { expect(res).toEqual(aliasMap); }); }); + +describe('computeRatio', () => { + it('should correctly compute ratio of two numbers', () => { + expect(computeRatio('10', '2')).toBe(5); + expect(computeRatio('3', '4')).toBe(0.75); + expect(computeRatio('0', '5')).toBe(0); + }); + + it('should return NaN when denominator is zero', () => { + expect(isNaN(computeRatio('10', '0'))).toBe(true); + }); + + it('should return NaN for non-numeric inputs', () => { + expect(isNaN(computeRatio('abc', '2'))).toBe(true); + expect(isNaN(computeRatio('10', 'xyz'))).toBe(true); + expect(isNaN(computeRatio('abc', 'xyz'))).toBe(true); + expect(isNaN(computeRatio('', '5'))).toBe(true); + }); + + it('should handle string representations of numbers', () => { + expect(computeRatio('10.5', '2')).toBe(5.25); + expect(computeRatio('-10', '5')).toBe(-2); + expect(computeRatio('10', '-5')).toBe(-2); + }); + + it('should handle number input types', () => { + expect(computeRatio(10, 2)).toBe(5); + expect(computeRatio(3, 4)).toBe(0.75); + expect(computeRatio(10.5, 2)).toBe(5.25); + expect(computeRatio(0, 5)).toBe(0); + expect(isNaN(computeRatio(10, 0))).toBe(true); + expect(computeRatio(-10, 5)).toBe(-2); + }); + + it('should handle mixed string and number inputs', () => { + expect(computeRatio('10', 2)).toBe(5); + expect(computeRatio(10, '2')).toBe(5); + expect(computeRatio(3, '4')).toBe(0.75); + expect(isNaN(computeRatio(10, ''))).toBe(true); + }); +}); + +describe('computeResultSetRatio', () => { + it('should compute ratio for a valid result set with timestamp column', () => { + const mockResultSet: ResponseJSON = { + meta: [ + { name: 'timestamp', type: 'DateTime' }, + { name: 'requests', type: 'UInt64' }, + { name: 'errors', type: 'UInt64' }, + ], + data: [ + { timestamp: '2025-04-15 10:00:00', requests: '100', errors: '10' }, + { timestamp: '2025-04-15 11:00:00', requests: '200', errors: '20' }, + ], + rows: 2, + statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 }, + }; + + const result = computeResultSetRatio(mockResultSet); + + expect(result.meta.length).toBe(2); + expect(result.meta[0].name).toBe('requests/errors'); + expect(result.meta[0].type).toBe('Float64'); + expect(result.meta[1].name).toBe('timestamp'); + + expect(result.data.length).toBe(2); + expect(result.data[0]['requests/errors']).toBe(10); + expect(result.data[0].timestamp).toBe('2025-04-15 10:00:00'); + expect(result.data[1]['requests/errors']).toBe(10); + expect(result.data[1].timestamp).toBe('2025-04-15 11:00:00'); + }); + + it('should compute ratio for a valid result set without timestamp column', () => { + const mockResultSet: ResponseJSON = { + meta: [ + { name: 'requests', type: 'UInt64' }, + { name: 'errors', type: 'UInt64' }, + ], + data: [{ requests: '100', errors: '10' }], + rows: 1, + statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 }, + }; + + const result = computeResultSetRatio(mockResultSet); + + expect(result.meta.length).toBe(1); + expect(result.meta[0].name).toBe('requests/errors'); + expect(result.meta[0].type).toBe('Float64'); + + expect(result.data.length).toBe(1); + expect(result.data[0]['requests/errors']).toBe(10); + expect(result.data[0].timestamp).toBeUndefined(); + }); + + it('should handle NaN values in ratio computation', () => { + const mockResultSet: ResponseJSON = { + meta: [ + { name: 'timestamp', type: 'DateTime' }, + { name: 'requests', type: 'UInt64' }, + { name: 'errors', type: 'UInt64' }, + ], + data: [ + { timestamp: '2025-04-15 10:00:00', requests: '100', errors: '0' }, + { timestamp: '2025-04-15 11:00:00', requests: 'invalid', errors: '20' }, + ], + rows: 2, + statistics: { elapsed: 0.1, rows_read: 2, bytes_read: 100 }, + }; + + const result = computeResultSetRatio(mockResultSet); + + expect(result.data.length).toBe(2); + expect(isNaN(result.data[0]['requests/errors'])).toBe(true); + expect(isNaN(result.data[1]['requests/errors'])).toBe(true); + }); + + it('should throw error when result set has insufficient columns', () => { + const mockResultSet: ResponseJSON = { + meta: [ + { name: 'timestamp', type: 'DateTime' }, + { name: 'requests', type: 'UInt64' }, + ], + data: [{ timestamp: '2025-04-15 10:00:00', requests: '100' }], + rows: 1, + statistics: { elapsed: 0.1, rows_read: 1, bytes_read: 50 }, + }; + + expect(() => computeResultSetRatio(mockResultSet)).toThrow( + /Unable to compute ratio/, + ); + }); +}); diff --git a/packages/common-utils/src/clickhouse.ts b/packages/common-utils/src/clickhouse.ts index 6a3853ef1..4f2c5749f 100644 --- a/packages/common-utils/src/clickhouse.ts +++ b/packages/common-utils/src/clickhouse.ts @@ -6,10 +6,18 @@ import type { } from '@clickhouse/client-common'; import { isSuccessfulResponse } from '@clickhouse/client-common'; import * as SQLParser from 'node-sql-parser'; - -import { SQLInterval } from '@/types'; +import objectHash from 'object-hash'; + +import { + renderChartConfig, + setChartSelectsAlias, + splitChartConfigs, +} from '@/renderChartConfig'; +import { ChartConfigWithOptDateRange, SQLInterval } from '@/types'; import { hashCode, isBrowser, isNode, timeBucketByGranularity } from '@/utils'; +import { Metadata } from './metadata'; + export enum JSDataType { Array = 'array', Date = 'date', @@ -251,6 +259,74 @@ export function extractColumnReference( return iterations < maxIterations ? sql.trim() : null; } +const castToNumber = (value: string | number) => { + if (typeof value === 'string') { + if (value.trim() === '') { + return NaN; + } + return Number(value); + } + return value; +}; + +export const computeRatio = ( + numeratorInput: string | number, + denominatorInput: string | number, +) => { + const numerator = castToNumber(numeratorInput); + const denominator = castToNumber(denominatorInput); + + if (isNaN(numerator) || isNaN(denominator) || denominator === 0) { + return NaN; + } + + return numerator / denominator; +}; + +export const computeResultSetRatio = (resultSet: ResponseJSON) => { + const _meta = resultSet.meta; + const _data = resultSet.data; + const timestampColumn = inferTimestampColumn(_meta ?? []); + const _restColumns = _meta?.filter(m => m.name !== timestampColumn?.name); + const firstColumn = _restColumns?.[0]; + const secondColumn = _restColumns?.[1]; + if (!firstColumn || !secondColumn) { + throw new Error( + `Unable to compute ratio - meta information: ${JSON.stringify(_meta)}.`, + ); + } + const ratioColumnName = `${firstColumn.name}/${secondColumn.name}`; + const result = { + ...resultSet, + data: _data.map(row => ({ + [ratioColumnName]: computeRatio( + row[firstColumn.name], + row[secondColumn.name], + ), + ...(timestampColumn + ? { + [timestampColumn.name]: row[timestampColumn.name], + } + : {}), + })), + meta: [ + { + name: ratioColumnName, + type: 'Float64', + }, + ...(timestampColumn + ? [ + { + name: timestampColumn.name, + type: timestampColumn.type, + }, + ] + : []), + ], + }; + return result; +}; + export type ClickhouseClientOptions = { host: string; username?: string; @@ -382,6 +458,101 @@ export class ClickhouseClient { ); } } + + // TODO: only used when multi-series 'metrics' is selected (no effects on the events chart) + // eventually we want to generate union CTEs on the db side instead of computing it on the client side + async queryChartConfig({ + config, + metadata, + opts, + }: { + config: ChartConfigWithOptDateRange; + metadata: Metadata; + opts?: { + abort_signal?: AbortSignal; + clickhouse_settings?: Record; + }; + }): Promise>> { + config = setChartSelectsAlias(config); + const queries: ChSql[] = await Promise.all( + splitChartConfigs(config).map(c => renderChartConfig(c, metadata)), + ); + + const isTimeSeries = config.displayType === 'line'; + + const resultSets = await Promise.all( + queries.map(async query => { + const resp = await this.query<'JSON'>({ + query: query.sql, + query_params: query.params, + format: 'JSON', + abort_signal: opts?.abort_signal, + connectionId: config.connection, + clickhouse_settings: opts?.clickhouse_settings, + }); + return resp.json(); + }), + ); + + if (resultSets.length === 1) { + return resultSets[0]; + } + // metrics -> join resultSets + else if (resultSets.length > 1) { + const metaSet = new Map(); + const tsBucketMap = new Map>(); + for (const resultSet of resultSets) { + // set up the meta data + if (Array.isArray(resultSet.meta)) { + for (const meta of resultSet.meta) { + const key = meta.name; + if (!metaSet.has(key)) { + metaSet.set(key, meta); + } + } + } + + const timestampColumn = inferTimestampColumn(resultSet.meta ?? []); + const numericColumn = inferNumericColumn(resultSet.meta ?? []); + const numericColumnName = numericColumn?.[0]?.name; + for (const row of resultSet.data) { + const _rowWithoutValue = numericColumnName + ? Object.fromEntries( + Object.entries(row).filter( + ([key]) => key !== numericColumnName, + ), + ) + : { ...row }; + const ts = + timestampColumn != null + ? row[timestampColumn.name] + : isTimeSeries + ? objectHash(_rowWithoutValue) + : '__FIXED_TIMESTAMP__'; + if (tsBucketMap.has(ts)) { + const existingRow = tsBucketMap.get(ts); + tsBucketMap.set(ts, { + ...existingRow, + ...row, + }); + } else { + tsBucketMap.set(ts, row); + } + } + } + + const isRatio = + config.seriesReturnType === 'ratio' && resultSets.length === 2; + + const _resultSet: ResponseJSON = { + meta: Array.from(metaSet.values()), + data: Array.from(tsBucketMap.values()), + }; + // TODO: we should compute the ratio on the db side + return isRatio ? computeResultSetRatio(_resultSet) : _resultSet; + } + throw new Error('No result sets'); + } } export const testLocalConnection = async ({ diff --git a/packages/common-utils/src/renderChartConfig.ts b/packages/common-utils/src/renderChartConfig.ts index f51b24dbd..e15dc6272 100644 --- a/packages/common-utils/src/renderChartConfig.ts +++ b/packages/common-utils/src/renderChartConfig.ts @@ -72,6 +72,42 @@ function isUsingGranularity( ); } +export const isMetricChartConfig = ( + chartConfig: ChartConfigWithOptDateRange, +) => { + return chartConfig.metricTables != null; +}; + +// TODO: apply this to all chart configs +export const setChartSelectsAlias = (config: ChartConfigWithOptDateRange) => { + if (Array.isArray(config.select) && isMetricChartConfig(config)) { + return { + ...config, + select: config.select.map(s => ({ + ...s, + alias: `${s.aggFn}(${s.metricName})`, + })), + }; + } + return config; +}; + +export const splitChartConfigs = (config: ChartConfigWithOptDateRange) => { + // only split metric queries for now + if (isMetricChartConfig(config) && Array.isArray(config.select)) { + const _configs: ChartConfigWithOptDateRange[] = []; + // split the query into multiple queries + for (const select of config.select) { + _configs.push({ + ...config, + select: [select], + }); + } + return _configs; + } + return [config]; +}; + const INVERSE_OPERATOR_MAP = { '=': '!=', '>': '<=', @@ -316,7 +352,10 @@ async function renderSelectList( tableName: chartConfig.from.tableName, }); - return Promise.all( + const isRatio = + chartConfig.seriesReturnType === 'ratio' && selectList.length === 2; + + const selectsSQL = await Promise.all( selectList.map(async select => { const whereClause = await renderWhereExpression({ condition: select.aggCondition ?? '', @@ -361,6 +400,10 @@ async function renderSelectList( }`; }), ); + + return isRatio + ? [chSql`divide(${selectsSQL[0]}, ${selectsSQL[1]})`] + : selectsSQL; } function renderSortSpecificationList( @@ -1168,12 +1211,6 @@ async function translateMetricChartConfig( throw new Error(`no query support for metric type=${metricType}`); } -export const isMetricChartConfig = ( - chartConfig: ChartConfigWithOptDateRange, -) => { - return chartConfig.metricTables != null; -}; - export async function renderChartConfig( rawChartConfig: ChartConfigWithOptDateRange, metadata: Metadata,