mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
fix: Add alias WITH clause support to alert WHERE queries (#1851)
## Summary - When a saved search's SELECT contains aliases (e.g. `toString(Body) AS body`), the WHERE clause in alert queries may reference those aliases. Without WITH clauses defining them, the query fails. - Added `computeAliasWithClauses` to discover aliases from the saved search's SELECT and inject them as WITH clauses into both the alert check query and the sample logs query. - Moved the shared `aliasMapToWithClauses` utility from `packages/app` to `packages/common-utils/src/core/utils.ts` so it can be reused across frontend and backend, eliminating duplication with the existing logic in `useRowWhere.tsx`. - Derived `WithClause` type from `ChartConfig['with']` instead of maintaining a separate type definition. ## Test plan - [x] Verify alerts with saved searches that use aliased columns (e.g. `toString(Body) AS body`) in both SELECT and WHERE fire correctly - [x] Verify alert sample logs in notifications render correctly for aliased columns - [x] Verify existing alerts without aliases continue to work unchanged - [x] Verify frontend row-click navigation still works (re-exported `aliasMapToWithClauses`) Ref: HDX-3601
This commit is contained in:
parent
b4f0558776
commit
32d45f738a
8 changed files with 491 additions and 39 deletions
|
|
@ -4149,6 +4149,317 @@ describe('checkAlerts', () => {
|
|||
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
|
||||
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
|
||||
});
|
||||
|
||||
it('SAVED_SEARCH alert with alias in select and where should trigger', async () => {
|
||||
const team = await createTeam({ name: 'My Team' });
|
||||
|
||||
const webhook = await new Webhook({
|
||||
team: team._id,
|
||||
service: 'slack',
|
||||
url: 'https://hooks.slack.com/services/123',
|
||||
name: 'My Webhook',
|
||||
}).save();
|
||||
|
||||
const teamWebhooksById = new Map<string, typeof webhook>([
|
||||
[webhook._id.toString(), webhook],
|
||||
]);
|
||||
|
||||
const connection = await Connection.create({
|
||||
team: team._id,
|
||||
name: 'Default',
|
||||
host: config.CLICKHOUSE_HOST,
|
||||
username: config.CLICKHOUSE_USER,
|
||||
password: config.CLICKHOUSE_PASSWORD,
|
||||
});
|
||||
|
||||
const source = await Source.create({
|
||||
kind: 'log',
|
||||
team: team._id,
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
timestampValueExpression: 'Timestamp',
|
||||
connection: connection.id,
|
||||
name: 'Logs',
|
||||
});
|
||||
|
||||
// Saved search uses an alias in select and references it in where (Lucene).
|
||||
// Note: Lucene `field:"value"` on alias columns (unknown type) generates
|
||||
// an exact-match query, so use unquoted syntax for substring matching.
|
||||
const savedSearch = await new SavedSearch({
|
||||
team: team._id,
|
||||
name: 'Aliased Search',
|
||||
select: 'toString(Body) AS body',
|
||||
where: 'body:wrong',
|
||||
whereLanguage: 'lucene',
|
||||
orderBy: 'Timestamp',
|
||||
source: source.id,
|
||||
tags: ['test'],
|
||||
}).save();
|
||||
|
||||
const clickhouseClient = new ClickhouseClient({
|
||||
host: connection.host,
|
||||
username: connection.username,
|
||||
password: connection.password,
|
||||
});
|
||||
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
const eventMs = new Date('2023-11-16T22:05:00.000Z');
|
||||
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: eventMs,
|
||||
SeverityText: 'error',
|
||||
Body: 'Oh no! Something went wrong!',
|
||||
},
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: eventMs,
|
||||
SeverityText: 'info',
|
||||
Body: 'Oh no! Something went wrong!',
|
||||
},
|
||||
]);
|
||||
|
||||
const details = await createAlertDetails(
|
||||
team,
|
||||
source,
|
||||
{
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.SAVED_SEARCH,
|
||||
savedSearch,
|
||||
},
|
||||
);
|
||||
|
||||
// Without alias WITH clause support, this would fail because
|
||||
// the alert query uses count(*) and the WHERE references `body`
|
||||
// which is only defined by the saved search's SELECT alias
|
||||
await processAlertAtTime(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
|
||||
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
|
||||
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('SAVED_SEARCH alert with alias in where should not trigger when no rows match', async () => {
|
||||
const team = await createTeam({ name: 'My Team' });
|
||||
|
||||
const webhook = await new Webhook({
|
||||
team: team._id,
|
||||
service: 'slack',
|
||||
url: 'https://hooks.slack.com/services/123',
|
||||
name: 'My Webhook',
|
||||
}).save();
|
||||
|
||||
const teamWebhooksById = new Map<string, typeof webhook>([
|
||||
[webhook._id.toString(), webhook],
|
||||
]);
|
||||
|
||||
const connection = await Connection.create({
|
||||
team: team._id,
|
||||
name: 'Default',
|
||||
host: config.CLICKHOUSE_HOST,
|
||||
username: config.CLICKHOUSE_USER,
|
||||
password: config.CLICKHOUSE_PASSWORD,
|
||||
});
|
||||
|
||||
const source = await Source.create({
|
||||
kind: 'log',
|
||||
team: team._id,
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
timestampValueExpression: 'Timestamp',
|
||||
connection: connection.id,
|
||||
name: 'Logs',
|
||||
});
|
||||
|
||||
// Alias in select, where references alias with a value that won't match
|
||||
const savedSearch = await new SavedSearch({
|
||||
team: team._id,
|
||||
name: 'Aliased Search No Match',
|
||||
select: 'toString(Body) AS body',
|
||||
where: 'body:"does not exist anywhere"',
|
||||
whereLanguage: 'lucene',
|
||||
orderBy: 'Timestamp',
|
||||
source: source.id,
|
||||
tags: ['test'],
|
||||
}).save();
|
||||
|
||||
const clickhouseClient = new ClickhouseClient({
|
||||
host: connection.host,
|
||||
username: connection.username,
|
||||
password: connection.password,
|
||||
});
|
||||
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
const eventMs = new Date('2023-11-16T22:05:00.000Z');
|
||||
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: eventMs,
|
||||
SeverityText: 'error',
|
||||
Body: 'Something went wrong!',
|
||||
},
|
||||
]);
|
||||
|
||||
const details = await createAlertDetails(
|
||||
team,
|
||||
source,
|
||||
{
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.SAVED_SEARCH,
|
||||
savedSearch,
|
||||
},
|
||||
);
|
||||
|
||||
await processAlertAtTime(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
|
||||
// No matching rows, so alert should remain in OK/INSUFFICIENT_DATA state
|
||||
const alertState = (await Alert.findById(details.alert.id))!.state;
|
||||
expect(alertState).not.toBe('ALERT');
|
||||
expect(slack.postMessageToWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SAVED_SEARCH alert with multiple aliases in select and where should trigger', async () => {
|
||||
const team = await createTeam({ name: 'My Team' });
|
||||
|
||||
const webhook = await new Webhook({
|
||||
team: team._id,
|
||||
service: 'slack',
|
||||
url: 'https://hooks.slack.com/services/123',
|
||||
name: 'My Webhook',
|
||||
}).save();
|
||||
|
||||
const teamWebhooksById = new Map<string, typeof webhook>([
|
||||
[webhook._id.toString(), webhook],
|
||||
]);
|
||||
|
||||
const connection = await Connection.create({
|
||||
team: team._id,
|
||||
name: 'Default',
|
||||
host: config.CLICKHOUSE_HOST,
|
||||
username: config.CLICKHOUSE_USER,
|
||||
password: config.CLICKHOUSE_PASSWORD,
|
||||
});
|
||||
|
||||
const source = await Source.create({
|
||||
kind: 'log',
|
||||
team: team._id,
|
||||
from: {
|
||||
databaseName: 'default',
|
||||
tableName: 'otel_logs',
|
||||
},
|
||||
timestampValueExpression: 'Timestamp',
|
||||
connection: connection.id,
|
||||
name: 'Logs',
|
||||
});
|
||||
|
||||
// Multiple aliases in select, where references one of them
|
||||
const savedSearch = await new SavedSearch({
|
||||
team: team._id,
|
||||
name: 'Multi Alias Search',
|
||||
select: 'toString(Body) AS body, ServiceName AS svc',
|
||||
where: 'svc:"api"',
|
||||
whereLanguage: 'lucene',
|
||||
orderBy: 'Timestamp',
|
||||
source: source.id,
|
||||
tags: ['test'],
|
||||
}).save();
|
||||
|
||||
const clickhouseClient = new ClickhouseClient({
|
||||
host: connection.host,
|
||||
username: connection.username,
|
||||
password: connection.password,
|
||||
});
|
||||
|
||||
const now = new Date('2023-11-16T22:12:00.000Z');
|
||||
const eventMs = new Date('2023-11-16T22:05:00.000Z');
|
||||
|
||||
await bulkInsertLogs([
|
||||
{
|
||||
ServiceName: 'api',
|
||||
Timestamp: eventMs,
|
||||
SeverityText: 'error',
|
||||
Body: 'Error from api service',
|
||||
},
|
||||
{
|
||||
ServiceName: 'web',
|
||||
Timestamp: eventMs,
|
||||
SeverityText: 'info',
|
||||
Body: 'Info from web service',
|
||||
},
|
||||
]);
|
||||
|
||||
const details = await createAlertDetails(
|
||||
team,
|
||||
source,
|
||||
{
|
||||
source: AlertSource.SAVED_SEARCH,
|
||||
channel: {
|
||||
type: 'webhook',
|
||||
webhookId: webhook._id.toString(),
|
||||
},
|
||||
interval: '5m',
|
||||
thresholdType: AlertThresholdType.ABOVE,
|
||||
threshold: 1,
|
||||
savedSearchId: savedSearch.id,
|
||||
},
|
||||
{
|
||||
taskType: AlertTaskType.SAVED_SEARCH,
|
||||
savedSearch,
|
||||
},
|
||||
);
|
||||
|
||||
await processAlertAtTime(
|
||||
now,
|
||||
details,
|
||||
clickhouseClient,
|
||||
connection.id,
|
||||
alertProvider,
|
||||
teamWebhooksById,
|
||||
);
|
||||
|
||||
// Only 1 log matches svc:"api", which meets threshold > 1
|
||||
expect((await Alert.findById(details.alert.id))!.state).toBe('ALERT');
|
||||
expect(slack.postMessageToWebhook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processAlert with materialized views', () => {
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@
|
|||
// --------------------------------------------------------
|
||||
import PQueue from '@esm2cjs/p-queue';
|
||||
import * as clickhouse from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { ResponseJSON } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import {
|
||||
chSqlToAliasMap,
|
||||
ResponseJSON,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node';
|
||||
import { tryOptimizeConfigWithMaterializedView } from '@hyperdx/common-utils/dist/core/materializedViews';
|
||||
import {
|
||||
getMetadata,
|
||||
Metadata,
|
||||
} from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
|
||||
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { timeBucketByGranularity } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
ChartConfigWithOptDateRange,
|
||||
|
|
@ -70,6 +75,33 @@ export const alertHasGroupBy = (details: AlertDetails): boolean => {
|
|||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a saved search's SELECT to discover column aliases (e.g. `toString(Body) AS body`)
|
||||
* and return them as WITH clauses that can be injected into alert/sample-log queries
|
||||
* whose own SELECT doesn't include those aliases.
|
||||
*/
|
||||
export async function computeAliasWithClauses(
|
||||
savedSearch: Pick<ISavedSearch, 'select' | 'where' | 'whereLanguage'>,
|
||||
source: ISource,
|
||||
metadata: Metadata,
|
||||
): Promise<ChartConfigWithOptDateRange['with']> {
|
||||
const resolvedSelect =
|
||||
savedSearch.select || source.defaultTableSelectExpression || '';
|
||||
const config: ChartConfigWithOptDateRange = {
|
||||
connection: '',
|
||||
displayType: DisplayType.Search,
|
||||
from: source.from,
|
||||
select: resolvedSelect,
|
||||
where: savedSearch.where,
|
||||
whereLanguage: savedSearch.whereLanguage,
|
||||
implicitColumnExpression: source.implicitColumnExpression,
|
||||
timestampValueExpression: source.timestampValueExpression,
|
||||
};
|
||||
const query = await renderChartConfig(config, metadata, source.querySettings);
|
||||
const aliasMap = chSqlToAliasMap(query);
|
||||
return aliasMapToWithClauses(aliasMap);
|
||||
}
|
||||
|
||||
export const doesExceedThreshold = (
|
||||
thresholdType: AlertThresholdType,
|
||||
threshold: number,
|
||||
|
|
@ -458,6 +490,29 @@ export const processAlert = async (
|
|||
|
||||
const metadata = getMetadata(clickhouseClient);
|
||||
|
||||
// For saved search alerts, the WHERE clause may reference aliased columns
|
||||
// from the saved search's select expression (e.g. `toString(Body) AS body`).
|
||||
// The alert query itself uses count(*), not the saved search's select,
|
||||
// so we render the saved search's select separately to discover aliases
|
||||
// and inject them as WITH clauses into the alert query.
|
||||
if (details.taskType === AlertTaskType.SAVED_SEARCH) {
|
||||
try {
|
||||
const withClauses = await computeAliasWithClauses(
|
||||
details.savedSearch,
|
||||
source,
|
||||
metadata,
|
||||
);
|
||||
if (withClauses) {
|
||||
chartConfig.with = withClauses;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
{ error: serializeError(e), alertId: alert.id },
|
||||
'Failed to compute alias WITH clauses for alert check',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optimize chart config with materialized views, if available
|
||||
const optimizedChartConfig = source?.materializedViews?.length
|
||||
? await tryOptimizeConfigWithMaterializedView(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,10 @@ import { IDashboard } from '@/models/dashboard';
|
|||
import { ISavedSearch } from '@/models/savedSearch';
|
||||
import { ISource } from '@/models/source';
|
||||
import { IWebhook } from '@/models/webhook';
|
||||
import { doesExceedThreshold } from '@/tasks/checkAlerts';
|
||||
import {
|
||||
computeAliasWithClauses,
|
||||
doesExceedThreshold,
|
||||
} from '@/tasks/checkAlerts';
|
||||
import {
|
||||
AlertProvider,
|
||||
PopulatedAlertChannel,
|
||||
|
|
@ -578,12 +581,14 @@ ${targetTemplate}`;
|
|||
}
|
||||
// TODO: show group + total count for group-by alerts
|
||||
// fetch sample logs
|
||||
const resolvedSelect =
|
||||
savedSearch.select || source.defaultTableSelectExpression || '';
|
||||
const chartConfig: ChartConfigWithOptDateRange = {
|
||||
connection: '', // no need for the connection id since clickhouse client is already initialized
|
||||
displayType: DisplayType.Search,
|
||||
dateRange: [startTime, endTime],
|
||||
from: source.from,
|
||||
select: savedSearch.select || source.defaultTableSelectExpression || '', // remove alert body if there is no select and defaultTableSelectExpression
|
||||
select: resolvedSelect,
|
||||
where: savedSearch.where,
|
||||
whereLanguage: savedSearch.whereLanguage,
|
||||
implicitColumnExpression: source.implicitColumnExpression,
|
||||
|
|
@ -597,6 +602,14 @@ ${targetTemplate}`;
|
|||
|
||||
let truncatedResults = '';
|
||||
try {
|
||||
const aliasWith = await computeAliasWithClauses(
|
||||
savedSearch,
|
||||
source,
|
||||
metadata,
|
||||
);
|
||||
if (aliasWith) {
|
||||
chartConfig.with = aliasWith;
|
||||
}
|
||||
const query = await renderChartConfig(
|
||||
chartConfig,
|
||||
metadata,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import {
|
||||
aliasMapToWithClauses,
|
||||
isBrowser,
|
||||
splitAndTrimWithBracket,
|
||||
} from '@hyperdx/common-utils/dist/core/utils';
|
||||
|
|
@ -93,7 +94,6 @@ import { TimePicker } from '@/components/TimePicker';
|
|||
import { IS_LOCAL_MODE } from '@/config';
|
||||
import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig';
|
||||
import { useExplainQuery } from '@/hooks/useExplainQuery';
|
||||
import { aliasMapToWithClauses } from '@/hooks/useRowWhere';
|
||||
import { withAppNav } from '@/layout';
|
||||
import {
|
||||
useCreateSavedSearch,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import {
|
||||
AlertInterval,
|
||||
SearchCondition,
|
||||
|
|
@ -9,7 +10,6 @@ import { Paper } from '@mantine/core';
|
|||
|
||||
import { DBTimeChart } from '@/components/DBTimeChart';
|
||||
import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig';
|
||||
import { aliasMapToWithClauses } from '@/hooks/useRowWhere';
|
||||
import { intervalToDateRange, intervalToGranularity } from '@/utils/alerts';
|
||||
|
||||
import { getAlertReferenceLines } from './Alerts';
|
||||
|
|
|
|||
|
|
@ -6,25 +6,20 @@ import {
|
|||
convertCHDataTypeToJSType,
|
||||
JSDataType,
|
||||
} from '@hyperdx/common-utils/dist/clickhouse';
|
||||
import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils';
|
||||
import { ChartConfig } from '@hyperdx/common-utils/dist/types';
|
||||
|
||||
const MAX_STRING_LENGTH = 512;
|
||||
|
||||
// Type for WITH clause entries, derived from ChartConfig's with property
|
||||
export type WithClause = NonNullable<ChartConfig['with']>[number];
|
||||
|
||||
// Internal row field names used by the table component for row tracking
|
||||
export const INTERNAL_ROW_FIELDS = {
|
||||
ID: '__hyperdx_id',
|
||||
ALIAS_WITH: '__hyperdx_alias_with',
|
||||
} as const;
|
||||
|
||||
// Type for WITH clause entries, matching ChartConfig's with property
|
||||
export type WithClause = {
|
||||
name: string;
|
||||
sql: {
|
||||
sql: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
isSubquery: boolean;
|
||||
};
|
||||
|
||||
// Result type for row WHERE clause with alias support
|
||||
export type RowWhereResult = {
|
||||
where: string;
|
||||
|
|
@ -133,29 +128,6 @@ export function processRowToWhereClause(
|
|||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an aliasMap to an array of WITH clause entries.
|
||||
* This allows aliases to be properly defined when querying for a specific row.
|
||||
*/
|
||||
export function aliasMapToWithClauses(
|
||||
aliasMap: Record<string, string | undefined> | undefined,
|
||||
): WithClause[] {
|
||||
if (!aliasMap) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(aliasMap)
|
||||
.filter(([, value]) => value != null && value.trim() !== '')
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
sql: {
|
||||
sql: value as string,
|
||||
params: {},
|
||||
},
|
||||
isSubquery: false,
|
||||
}));
|
||||
}
|
||||
|
||||
export default function useRowWhere({
|
||||
meta,
|
||||
aliasMap,
|
||||
|
|
@ -186,7 +158,10 @@ export default function useRowWhere({
|
|||
);
|
||||
|
||||
// Memoize the aliasWith array since it only depends on aliasMap
|
||||
const aliasWith = useMemo(() => aliasMapToWithClauses(aliasMap), [aliasMap]);
|
||||
const aliasWith = useMemo(
|
||||
() => aliasMapToWithClauses(aliasMap) ?? [],
|
||||
[aliasMap],
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(row: Record<string, any>): RowWhereResult => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from '@/types';
|
||||
|
||||
import {
|
||||
aliasMapToWithClauses,
|
||||
convertToDashboardTemplate,
|
||||
extractSettingsClauseFromEnd,
|
||||
findJsonExpressions,
|
||||
|
|
@ -1726,4 +1727,71 @@ describe('utils', () => {
|
|||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aliasMapToWithClauses', () => {
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(aliasMapToWithClauses(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for empty alias map', () => {
|
||||
expect(aliasMapToWithClauses({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when all values are undefined', () => {
|
||||
expect(
|
||||
aliasMapToWithClauses({ body: undefined, service: undefined }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when all values are empty strings', () => {
|
||||
expect(
|
||||
aliasMapToWithClauses({ body: '', service: ' ' }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert a single alias to a WITH clause', () => {
|
||||
expect(aliasMapToWithClauses({ body: 'toString(Body)' })).toEqual([
|
||||
{
|
||||
name: 'body',
|
||||
sql: { sql: 'toString(Body)', params: {} },
|
||||
isSubquery: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert multiple aliases to WITH clauses', () => {
|
||||
const result = aliasMapToWithClauses({
|
||||
body: 'toString(Body)',
|
||||
service: "ResourceAttributes['service.name']",
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: 'body',
|
||||
sql: { sql: 'toString(Body)', params: {} },
|
||||
isSubquery: false,
|
||||
},
|
||||
{
|
||||
name: 'service',
|
||||
sql: {
|
||||
sql: "ResourceAttributes['service.name']",
|
||||
params: {},
|
||||
},
|
||||
isSubquery: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip entries with undefined or empty values', () => {
|
||||
const result = aliasMapToWithClauses({
|
||||
body: 'toString(Body)',
|
||||
empty: '',
|
||||
blank: ' ',
|
||||
missing: undefined,
|
||||
service: "ResourceAttributes['service.name']",
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result![0].name).toBe('body');
|
||||
expect(result![1].name).toBe('service');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { z } from 'zod';
|
|||
export { default as objectHash } from 'object-hash';
|
||||
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartConfigWithDateRange,
|
||||
ChartConfigWithOptTimestamp,
|
||||
DashboardFilter,
|
||||
|
|
@ -927,3 +928,32 @@ export function parseTokenizerFromTextIndex({
|
|||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an aliasMap (e.g. from chSqlToAliasMap) to an array of WITH clause entries.
|
||||
* These WITH clauses define aliases as expressions (isSubquery: false),
|
||||
* making them available in WHERE and other clauses.
|
||||
*/
|
||||
export function aliasMapToWithClauses(
|
||||
aliasMap: Record<string, string | undefined> | undefined,
|
||||
): ChartConfig['with'] {
|
||||
if (!aliasMap) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const withClauses = Object.entries(aliasMap)
|
||||
.filter(
|
||||
(entry): entry is [string, string] =>
|
||||
entry[1] != null && entry[1].trim() !== '',
|
||||
)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
sql: {
|
||||
sql: value,
|
||||
params: {},
|
||||
},
|
||||
isSubquery: false,
|
||||
}));
|
||||
|
||||
return withClauses.length > 0 ? withClauses : undefined;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue