mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
feat: Support ClickHouse datasource plugin macros in Raw SQL charts (#1922)
## Summary This PR extends Raw SQL Charts to support [macros that are supported by the Grafana ClickHouse plugin](https://github.com/grafana/clickhouse-datasource?tab=readme-ov-file#macros). Query Params and Macros are also now included as auto-complete suggestions in the SQL Editor. ### Screenshots or video <img width="1434" height="1080" alt="Screenshot 2026-03-16 at 12 53 03 PM" src="https://github.com/user-attachments/assets/07f753e4-28f1-43f4-8add-f123dae0b12a" /> ### How to test locally or on Vercel This can be tested in Vercel preview - just reference the supported macros in a raw SQL chart. ### References - Linear Issue: Closes HDX-3651 - Related PRs:
This commit is contained in:
parent
74d925949c
commit
4cee5d698b
11 changed files with 556 additions and 21 deletions
5
.changeset/violet-ligers-destroy.md
Normal file
5
.changeset/violet-ligers-destroy.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/common-utils": patch
|
||||
---
|
||||
|
||||
feat: Support ClickHouse datasource plugin macros in Raw SQL chart configs
|
||||
|
|
@ -4,10 +4,13 @@ import {
|
|||
TableConnection,
|
||||
tcFromSource,
|
||||
} from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import { MACRO_SUGGESTIONS } from '@hyperdx/common-utils/dist/macros';
|
||||
import { QUERY_PARAMS_BY_DISPLAY_TYPE } from '@hyperdx/common-utils/dist/rawSqlParams';
|
||||
import { DisplayType, SourceKind } from '@hyperdx/common-utils/dist/types';
|
||||
import { Box, Button, Group, Stack, Text } from '@mantine/core';
|
||||
|
||||
import { SQLEditorControlled } from '@/components/SQLEditor/SQLEditor';
|
||||
import { type SQLCompletion } from '@/components/SQLEditor/utils';
|
||||
import useResizable from '@/hooks/useResizable';
|
||||
import { useSources } from '@/source';
|
||||
import { getAllMetricTables } from '@/utils';
|
||||
|
|
@ -51,6 +54,29 @@ export default function RawSqlChartEditor({
|
|||
|
||||
const placeholderSQl = SQL_PLACEHOLDERS[displayType ?? DisplayType.Table];
|
||||
|
||||
const additionalCompletions: SQLCompletion[] = useMemo(() => {
|
||||
const effectiveDisplayType = displayType ?? DisplayType.Table;
|
||||
const params = QUERY_PARAMS_BY_DISPLAY_TYPE[effectiveDisplayType];
|
||||
|
||||
const paramCompletions: SQLCompletion[] = params.map(({ name, type }) => ({
|
||||
label: `{${name}:${type}}`,
|
||||
apply: `{${name}:${type}`, // Omit the closing } because the editor will have added it when the user types {
|
||||
detail: 'param',
|
||||
type: 'variable',
|
||||
}));
|
||||
|
||||
const macroCompletions: SQLCompletion[] = MACRO_SUGGESTIONS.map(
|
||||
({ name, argCount }) => ({
|
||||
label: `$__${name}`,
|
||||
apply: argCount > 0 ? `$__${name}(` : `$__${name}`,
|
||||
detail: 'macro',
|
||||
type: 'function',
|
||||
}),
|
||||
);
|
||||
|
||||
return [...paramCompletions, ...macroCompletions];
|
||||
}, [displayType]);
|
||||
|
||||
const tableConnections: TableConnection[] = useMemo(() => {
|
||||
if (!sources) return [];
|
||||
return sources
|
||||
|
|
@ -97,6 +123,7 @@ export default function RawSqlChartEditor({
|
|||
enableLineWrapping
|
||||
placeholder={placeholderSQl}
|
||||
tableConnections={tableConnections}
|
||||
additionalCompletions={additionalCompletions}
|
||||
/>
|
||||
<div className={resizeStyles.resizeYHandle} onMouseDown={startResize} />
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
import { DisplayType } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
|
|
@ -97,7 +98,7 @@ export function RawSqlChartInstructions({
|
|||
<Text size="xs" fw="bold">
|
||||
The following parameters can be referenced in this chart's SQL:
|
||||
</Text>
|
||||
<List size="xs" withPadding spacing={3} mb="xs">
|
||||
<List size="xs" withPadding spacing={3}>
|
||||
{availableParams.map(({ name, type, description }) => (
|
||||
<List.Item key={name}>
|
||||
<ParamSnippet
|
||||
|
|
@ -140,6 +141,16 @@ export function RawSqlChartInstructions({
|
|||
<Code fz="xs" block>
|
||||
{QUERY_PARAM_EXAMPLES[displayType]}
|
||||
</Code>
|
||||
<Text size="xs" mt="xs">
|
||||
Macros from the{' '}
|
||||
<Anchor
|
||||
href="https://github.com/grafana/clickhouse-datasource?tab=readme-ov-file#macros"
|
||||
target="_blank"
|
||||
>
|
||||
ClickHouse Datasource Grafana Plugin
|
||||
</Anchor>{' '}
|
||||
may also be used.
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
createCodeMirrorSqlDialect,
|
||||
createCodeMirrorStyleTheme,
|
||||
DEFAULT_CODE_MIRROR_BASIC_SETUP,
|
||||
type SQLCompletion,
|
||||
} from './utils';
|
||||
|
||||
type SQLEditorProps = {
|
||||
|
|
@ -26,6 +27,7 @@ type SQLEditorProps = {
|
|||
height?: string;
|
||||
enableLineWrapping?: boolean;
|
||||
tableConnections?: TableConnection[];
|
||||
additionalCompletions?: SQLCompletion[];
|
||||
};
|
||||
|
||||
export default function SQLEditor({
|
||||
|
|
@ -35,6 +37,7 @@ export default function SQLEditor({
|
|||
height,
|
||||
enableLineWrapping = false,
|
||||
tableConnections,
|
||||
additionalCompletions,
|
||||
}: SQLEditorProps) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const ref = useRef<ReactCodeMirrorRef>(null);
|
||||
|
|
@ -66,13 +69,14 @@ export default function SQLEditor({
|
|||
effects: compartmentRef.current.reconfigure(
|
||||
createCodeMirrorSqlDialect({
|
||||
identifiers,
|
||||
additionalCompletions,
|
||||
includeAggregateFunctions: true,
|
||||
includeRegularFunctions: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
},
|
||||
[fields, tableConnections],
|
||||
[additionalCompletions, fields, tableConnections],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -11,20 +11,29 @@ import {
|
|||
ALL_KEYWORDS,
|
||||
REGULAR_FUNCTIONS,
|
||||
} from './constants';
|
||||
|
||||
export type SQLCompletion = {
|
||||
label: string;
|
||||
apply?: string;
|
||||
detail?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a custom CodeMirror completion source for SQL identifiers (column names, table
|
||||
* names, functions, etc.) that inserts them verbatim, without quoting.
|
||||
*/
|
||||
function createIdentifierCompletionSource(completions: Completion[]) {
|
||||
return (context: CompletionContext) => {
|
||||
// Match word characters, dots, single quotes, and brackets to support
|
||||
// identifiers like `ResourceAttributes['service.name']`
|
||||
const prefix = context.matchBefore(/[\w.'[\]]+/);
|
||||
// Match word characters, dots, single quotes, brackets, $, {, }, and :
|
||||
// to support identifiers like `ResourceAttributes['service.name']`,
|
||||
// macros like `$__dateFilter`, and query params like `{name:Type}`
|
||||
const prefix = context.matchBefore(/[\w.'[\]${}:]+/);
|
||||
if (!prefix && !context.explicit) return null;
|
||||
return {
|
||||
from: prefix?.from ?? context.pos,
|
||||
options: completions,
|
||||
validFor: /^[\w.'[\]]*$/,
|
||||
validFor: /^[\w.'[\]${}:]*$/,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -32,11 +41,13 @@ function createIdentifierCompletionSource(completions: Completion[]) {
|
|||
export const createCodeMirrorSqlDialect = ({
|
||||
identifiers,
|
||||
keywords = ALL_KEYWORDS,
|
||||
additionalCompletions = [],
|
||||
includeRegularFunctions = false,
|
||||
includeAggregateFunctions = false,
|
||||
}: {
|
||||
identifiers: string[];
|
||||
keywords?: string[];
|
||||
additionalCompletions?: SQLCompletion[];
|
||||
includeRegularFunctions?: boolean;
|
||||
includeAggregateFunctions?: boolean;
|
||||
}) => {
|
||||
|
|
@ -60,6 +71,7 @@ export const createCodeMirrorSqlDialect = ({
|
|||
apply: `${fn}(`,
|
||||
}))
|
||||
: []),
|
||||
...additionalCompletions,
|
||||
];
|
||||
|
||||
return [
|
||||
|
|
|
|||
104
packages/common-utils/src/__tests__/macros.test.ts
Normal file
104
packages/common-utils/src/__tests__/macros.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { replaceMacros } from '../macros';
|
||||
|
||||
describe('replaceMacros', () => {
|
||||
it('should replace $__fromTime with seconds-precision DateTime', () => {
|
||||
expect(replaceMacros('SELECT $__fromTime')).toBe(
|
||||
'SELECT toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__toTime with seconds-precision DateTime', () => {
|
||||
expect(replaceMacros('SELECT $__toTime')).toBe(
|
||||
'SELECT toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__fromTime_ms with millisecond-precision DateTime64', () => {
|
||||
expect(replaceMacros('SELECT $__fromTime_ms')).toBe(
|
||||
'SELECT fromUnixTimestamp64Milli({startDateMilliseconds:Int64})',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__toTime_ms with millisecond-precision DateTime64', () => {
|
||||
expect(replaceMacros('SELECT $__toTime_ms')).toBe(
|
||||
'SELECT fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__timeFilter with seconds-precision range filter', () => {
|
||||
const result = replaceMacros('WHERE $__timeFilter(ts)');
|
||||
expect(result).toBe(
|
||||
'WHERE ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__timeFilter_ms with millisecond-precision range filter', () => {
|
||||
const result = replaceMacros('WHERE $__timeFilter_ms(ts)');
|
||||
expect(result).toBe(
|
||||
'WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__dateFilter with date-only range filter', () => {
|
||||
const result = replaceMacros('WHERE $__dateFilter(d)');
|
||||
expect(result).toBe(
|
||||
'WHERE d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__dateTimeFilter with combined date and time filter', () => {
|
||||
const result = replaceMacros('WHERE $__dateTimeFilter(d, ts)');
|
||||
expect(result).toBe(
|
||||
'WHERE (d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))) AND (ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64})))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__dt as an alias for dateTimeFilter', () => {
|
||||
const result = replaceMacros('WHERE $__dt(d, ts)');
|
||||
expect(result).toBe(
|
||||
'WHERE (d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))) AND (ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64})))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__timeInterval with interval bucketing expression', () => {
|
||||
const result = replaceMacros('SELECT $__timeInterval(ts)');
|
||||
expect(result).toBe(
|
||||
'SELECT toStartOfInterval(toDateTime(ts), INTERVAL {intervalSeconds:Int64} second)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__timeInterval_ms with millisecond interval bucketing', () => {
|
||||
const result = replaceMacros('SELECT $__timeInterval_ms(ts)');
|
||||
expect(result).toBe(
|
||||
'SELECT toStartOfInterval(toDateTime64(ts, 3), INTERVAL {intervalMilliseconds:Int64} millisecond)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace $__interval_s with interval seconds param', () => {
|
||||
expect(replaceMacros('INTERVAL $__interval_s second')).toBe(
|
||||
'INTERVAL {intervalSeconds:Int64} second',
|
||||
);
|
||||
});
|
||||
|
||||
it('should replace multiple macros in one query', () => {
|
||||
const sql =
|
||||
'SELECT $__timeInterval(ts), count() FROM t WHERE $__timeFilter(ts) GROUP BY 1';
|
||||
const result = replaceMacros(sql);
|
||||
expect(result).toContain('toStartOfInterval');
|
||||
expect(result).toContain(
|
||||
'ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on wrong argument count', () => {
|
||||
expect(() => replaceMacros('$__timeFilter(a, b)')).toThrow(
|
||||
'expects 1 argument(s), but got 2',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on missing close bracket', () => {
|
||||
expect(() => replaceMacros('$__timeFilter(col')).toThrow(
|
||||
'Failed to parse macro arguments',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { DisplayType } from '@/types';
|
||||
|
||||
import { renderRawSqlChartConfig } from '../rawSqlParams';
|
||||
import { renderRawSqlChartConfig } from '../core/renderChartConfig';
|
||||
|
||||
describe('renderRawSqlChartConfig', () => {
|
||||
describe('DisplayType.Table', () => {
|
||||
|
|
|
|||
|
|
@ -1517,4 +1517,166 @@ describe('renderChartConfig', () => {
|
|||
endDateMilliseconds: end.getTime(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw sql macro replacement', () => {
|
||||
const start = new Date('2024-01-01T00:00:00.000Z');
|
||||
const end = new Date('2024-01-02T00:00:00.000Z');
|
||||
|
||||
it('replaces $__dateFilter macro in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT * FROM logs WHERE $__dateFilter(d)',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
expect(result.params.startDateMilliseconds).toBe(start.getTime());
|
||||
expect(result.params.endDateMilliseconds).toBe(end.getTime());
|
||||
});
|
||||
|
||||
it('replaces $__timeFilter macro in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT * FROM logs WHERE $__timeFilter(ts)',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces $__timeFilter_ms macro in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT * FROM logs WHERE $__timeFilter_ms(ts)',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces $__fromTime and $__toTime macros in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate:
|
||||
'SELECT * FROM logs WHERE ts >= $__fromTime AND ts <= $__toTime',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces $__fromTime_ms and $__toTime_ms macros in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate:
|
||||
'SELECT * FROM logs WHERE ts >= $__fromTime_ms AND ts <= $__toTime_ms',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE ts >= fromUnixTimestamp64Milli({startDateMilliseconds:Int64}) AND ts <= fromUnixTimestamp64Milli({endDateMilliseconds:Int64})',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces $__dateTimeFilter macro in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate: 'SELECT * FROM logs WHERE $__dateTimeFilter(d, ts)',
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT * FROM logs WHERE (d >= toDate(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND d <= toDate(fromUnixTimestamp64Milli({endDateMilliseconds:Int64}))) AND (ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64})) AND ts <= toDateTime(fromUnixTimestamp64Milli({endDateMilliseconds:Int64})))',
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces $__timeInterval macro in raw sql Line config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate:
|
||||
'SELECT $__timeInterval(ts) AS t, count() FROM logs WHERE $__timeFilter(ts) GROUP BY t',
|
||||
connection: 'conn-1',
|
||||
displayType: DisplayType.Line,
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toContain(
|
||||
'toStartOfInterval(toDateTime(ts), INTERVAL {intervalSeconds:Int64} second)',
|
||||
);
|
||||
expect(result.sql).toContain(
|
||||
'ts >= toDateTime(fromUnixTimestamp64Milli({startDateMilliseconds:Int64}))',
|
||||
);
|
||||
expect(result.params.intervalSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('replaces $__interval_s macro in raw sql config', async () => {
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate:
|
||||
'SELECT toStartOfInterval(ts, INTERVAL $__interval_s second) FROM logs',
|
||||
connection: 'conn-1',
|
||||
displayType: DisplayType.Line,
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(
|
||||
'SELECT toStartOfInterval(ts, INTERVAL {intervalSeconds:Int64} second) FROM logs',
|
||||
);
|
||||
expect(result.params.intervalSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes through raw sql with no macros unchanged', async () => {
|
||||
const sql =
|
||||
'SELECT count() FROM logs WHERE ts >= {startDateMilliseconds:Int64}';
|
||||
const result = await renderChartConfig(
|
||||
{
|
||||
configType: 'sql',
|
||||
sqlTemplate: sql,
|
||||
connection: 'conn-1',
|
||||
dateRange: [start, end],
|
||||
},
|
||||
mockMetadata,
|
||||
undefined,
|
||||
);
|
||||
expect(result.sql).toBe(sql);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
splitAndTrimWithBracket,
|
||||
} from '@/core/utils';
|
||||
import { isBuilderChartConfig, isRawSqlChartConfig } from '@/guards';
|
||||
import { replaceMacros } from '@/macros';
|
||||
import { CustomSchemaSQLSerializerV2, SearchQueryBuilder } from '@/queryParser';
|
||||
import { renderRawSqlChartConfig } from '@/rawSqlParams';
|
||||
import { QUERY_PARAMS_BY_DISPLAY_TYPE } from '@/rawSqlParams';
|
||||
import {
|
||||
AggregateFunction,
|
||||
AggregateFunctionWithCombinators,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
ChSqlSchema,
|
||||
CteChartConfig,
|
||||
DateRange,
|
||||
DisplayType,
|
||||
MetricsDataType,
|
||||
QuerySettings,
|
||||
RawSqlChartConfig,
|
||||
|
|
@ -1402,6 +1404,24 @@ async function translateMetricChartConfig(
|
|||
throw new Error(`no query support for metric type=${metricType}`);
|
||||
}
|
||||
|
||||
export function renderRawSqlChartConfig(
|
||||
chartConfig: RawSqlChartConfig & Partial<DateRange>,
|
||||
): ChSql {
|
||||
const displayType = chartConfig.displayType ?? DisplayType.Table;
|
||||
|
||||
const sqlWithMacrosReplaced = replaceMacros(chartConfig.sqlTemplate);
|
||||
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
const queryParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];
|
||||
|
||||
return {
|
||||
sql: sqlWithMacrosReplaced,
|
||||
params: Object.fromEntries(
|
||||
queryParams.map(param => [param.name, param.get(chartConfig)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderChartConfig(
|
||||
rawChartConfig: ChartConfigWithOptDateRangeEx,
|
||||
metadata: Metadata,
|
||||
|
|
|
|||
201
packages/common-utils/src/macros.ts
Normal file
201
packages/common-utils/src/macros.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { splitAndTrimWithBracket } from './core/utils';
|
||||
import { renderQueryParam } from './rawSqlParams';
|
||||
|
||||
function expectArgs(macroName: string, args: string[], expected: number) {
|
||||
if (args.length !== expected) {
|
||||
throw new Error(
|
||||
`Macro '${macroName}' expects ${expected} argument(s), but got ${args.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers to render ClickHouse time conversions using query params
|
||||
const startMs = () => renderQueryParam('startDateMilliseconds');
|
||||
const endMs = () => renderQueryParam('endDateMilliseconds');
|
||||
const intervalS = () => renderQueryParam('intervalSeconds');
|
||||
const intervalMs = () => renderQueryParam('intervalMilliseconds');
|
||||
|
||||
const timeToDate = (msParam: string) =>
|
||||
`toDate(fromUnixTimestamp64Milli(${msParam}))`;
|
||||
const timeToDateTime = (msParam: string) =>
|
||||
`toDateTime(fromUnixTimestamp64Milli(${msParam}))`;
|
||||
const timeToDateTime64 = (msParam: string) =>
|
||||
`fromUnixTimestamp64Milli(${msParam})`;
|
||||
|
||||
type Macro = {
|
||||
name: string;
|
||||
argCount: number;
|
||||
replace: (args: string[]) => string;
|
||||
};
|
||||
|
||||
const MACROS: Macro[] = [
|
||||
{
|
||||
name: 'fromTime',
|
||||
argCount: 0,
|
||||
replace: () => timeToDateTime(startMs()),
|
||||
},
|
||||
{
|
||||
name: 'toTime',
|
||||
argCount: 0,
|
||||
replace: () => timeToDateTime(endMs()),
|
||||
},
|
||||
{
|
||||
name: 'fromTime_ms',
|
||||
argCount: 0,
|
||||
replace: () => timeToDateTime64(startMs()),
|
||||
},
|
||||
{
|
||||
name: 'toTime_ms',
|
||||
argCount: 0,
|
||||
replace: () => timeToDateTime64(endMs()),
|
||||
},
|
||||
{
|
||||
name: 'timeFilter',
|
||||
argCount: 1,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('timeFilter', args, 1);
|
||||
const [col] = args;
|
||||
return `${col} >= ${timeToDateTime(startMs())} AND ${col} <= ${timeToDateTime(endMs())}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timeFilter_ms',
|
||||
argCount: 1,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('timeFilter_ms', args, 1);
|
||||
const [col] = args;
|
||||
return `${col} >= ${timeToDateTime64(startMs())} AND ${col} <= ${timeToDateTime64(endMs())}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dateFilter',
|
||||
argCount: 1,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('dateFilter', args, 1);
|
||||
const [col] = args;
|
||||
return `${col} >= ${timeToDate(startMs())} AND ${col} <= ${timeToDate(endMs())}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dateTimeFilter',
|
||||
argCount: 2,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('dateTimeFilter', args, 2);
|
||||
const [dateCol, timeCol] = args;
|
||||
const dateFilter = `(${dateCol} >= ${timeToDate(startMs())} AND ${dateCol} <= ${timeToDate(endMs())})`;
|
||||
const timeFilter = `(${timeCol} >= ${timeToDateTime(startMs())} AND ${timeCol} <= ${timeToDateTime(endMs())})`;
|
||||
return `${dateFilter} AND ${timeFilter}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'dt',
|
||||
argCount: 2,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('dt', args, 2);
|
||||
const [dateCol, timeCol] = args;
|
||||
const dateFilter = `(${dateCol} >= ${timeToDate(startMs())} AND ${dateCol} <= ${timeToDate(endMs())})`;
|
||||
const timeFilter = `(${timeCol} >= ${timeToDateTime(startMs())} AND ${timeCol} <= ${timeToDateTime(endMs())})`;
|
||||
return `${dateFilter} AND ${timeFilter}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timeInterval',
|
||||
argCount: 1,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('timeInterval', args, 1);
|
||||
const [col] = args;
|
||||
return `toStartOfInterval(toDateTime(${col}), INTERVAL ${intervalS()} second)`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'timeInterval_ms',
|
||||
argCount: 1,
|
||||
replace: (args: string[]) => {
|
||||
expectArgs('timeInterval_ms', args, 1);
|
||||
const [col] = args;
|
||||
return `toStartOfInterval(toDateTime64(${col}, 3), INTERVAL ${intervalMs()} millisecond)`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'interval_s',
|
||||
argCount: 0,
|
||||
replace: () => intervalS(),
|
||||
},
|
||||
];
|
||||
|
||||
/** Macro metadata for autocomplete suggestions */
|
||||
export const MACRO_SUGGESTIONS = MACROS.map(({ name, argCount }) => ({
|
||||
name,
|
||||
argCount,
|
||||
}));
|
||||
|
||||
type MacroMatch = {
|
||||
full: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
function parseMacroArgs(argString: string): { args: string[]; length: number } {
|
||||
if (!argString.startsWith('(')) {
|
||||
return { args: [], length: 0 };
|
||||
}
|
||||
|
||||
// Find the matching close paren
|
||||
let unmatchedParens = 0;
|
||||
let closeParenIndex = -1;
|
||||
for (let i = 0; i < argString.length; i++) {
|
||||
const c = argString.charAt(i);
|
||||
if (c === '(') {
|
||||
unmatchedParens++;
|
||||
} else if (c === ')') {
|
||||
unmatchedParens--;
|
||||
if (unmatchedParens === 0) {
|
||||
closeParenIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closeParenIndex < 0) {
|
||||
return { args: [], length: -1 };
|
||||
}
|
||||
|
||||
const inner = argString.slice(1, closeParenIndex);
|
||||
const args = splitAndTrimWithBracket(inner);
|
||||
return { args, length: closeParenIndex + 1 };
|
||||
}
|
||||
|
||||
function findMacros(input: string, name: string): MacroMatch[] {
|
||||
// eslint-disable-next-line security/detect-non-literal-regexp
|
||||
const pattern = new RegExp(`\\$__${name}\\b`, 'g');
|
||||
const matches: MacroMatch[] = [];
|
||||
|
||||
for (const match of input.matchAll(pattern)) {
|
||||
const start = match.index!;
|
||||
const end = start + match[0].length;
|
||||
const { args, length } = parseMacroArgs(input.slice(end));
|
||||
|
||||
if (length < 0) {
|
||||
throw new Error('Failed to parse macro arguments');
|
||||
}
|
||||
|
||||
matches.push({ full: input.slice(start, end + length), args });
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function replaceMacros(sql: string): string {
|
||||
const sortedMacros = [...MACROS].sort(
|
||||
(m1, m2) => m2.name.length - m1.name.length,
|
||||
);
|
||||
|
||||
for (const macro of sortedMacros) {
|
||||
const matches = findMacros(sql, macro.name);
|
||||
|
||||
for (const match of matches) {
|
||||
sql = sql.replaceAll(match.full, macro.replace(match.args));
|
||||
}
|
||||
}
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
|
@ -110,18 +110,7 @@ export const QUERY_PARAM_EXAMPLES: Record<DisplayType, string> = {
|
|||
[DisplayType.Markdown]: '',
|
||||
};
|
||||
|
||||
export function renderRawSqlChartConfig(
|
||||
chartConfig: RawSqlChartConfig & Partial<DateRange>,
|
||||
): ChSql {
|
||||
const displayType = chartConfig.displayType ?? DisplayType.Table;
|
||||
|
||||
export function renderQueryParam(name: keyof typeof QUERY_PARAMS): string {
|
||||
// eslint-disable-next-line security/detect-object-injection
|
||||
const queryParams = QUERY_PARAMS_BY_DISPLAY_TYPE[displayType];
|
||||
|
||||
return {
|
||||
sql: chartConfig.sqlTemplate ?? '',
|
||||
params: Object.fromEntries(
|
||||
queryParams.map(param => [param.name, param.get(chartConfig)]),
|
||||
),
|
||||
};
|
||||
return `{${QUERY_PARAMS[name].name}:${QUERY_PARAMS[name].type}}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue