Compare commits

...

8 Commits

Author SHA1 Message Date
Vinícius Lourenço
0d6c27ec5f test(compositeQuery): add qsAlias tests 2026-06-16 21:27:44 -03:00
Vinícius Lourenço
9d919e166b feat(compositeQuery): add qsAlias adapter 2026-06-16 21:27:33 -03:00
Vinícius Lourenço
8c86885090 feat(compositeQuery): add baseline objects 2026-06-16 19:56:25 -03:00
Vinícius Lourenço
e7be5ee17d chore(query-builder-operators): add noop operator
This is used to help reduce the amount of diff on future baseline objects
2026-06-16 18:29:44 -03:00
Vinícius Lourenço
49c11f51ac test(useSafeNavigate): move helpers to add testing 2026-06-16 18:28:55 -03:00
Vinícius Lourenço
0c35a8f6e5 feat(compositeQuery): drop query param in favor of serializer functions 2026-06-16 18:26:39 -03:00
Vinícius Lourenço
2c076a3d50 feat(compositeQuery): add base structure for serializer with json as default 2026-06-16 18:25:03 -03:00
Vinícius Lourenço
086040799c feat(compositeQuery): add contract for serialize/deserialize query 2026-06-16 17:11:03 -03:00
77 changed files with 8080 additions and 302 deletions

View File

@@ -94,6 +94,7 @@
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
"qs": "6.15.2",
"rc-select": "14.10.0",
"react": "18.2.0",
"react-addons-update": "15.6.3",
@@ -168,6 +169,7 @@
"@types/lodash-es": "^4.17.4",
"@types/node": "^16.10.3",
"@types/papaparse": "5.3.7",
"@types/qs": "6.15.1",
"@types/react": "18.0.26",
"@types/react-addons-update": "0.14.21",
"@types/react-beautiful-dnd": "13.1.8",

View File

@@ -208,6 +208,9 @@ importers:
posthog-js:
specifier: 1.298.0
version: 1.298.0
qs:
specifier: 6.15.2
version: 6.15.2
rc-select:
specifier: 14.10.0
version: 14.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -389,6 +392,9 @@ importers:
'@types/papaparse':
specifier: 5.3.7
version: 5.3.7
'@types/qs':
specifier: 6.15.1
version: 6.15.1
'@types/react':
specifier: 18.0.26
version: 18.0.26
@@ -3558,6 +3564,9 @@ packages:
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/react-addons-update@0.14.21':
resolution: {integrity: sha512-HOxr0Hd8C1L4uw8DHyv2etqMVIj78oLEpe567/HgjoE+1Lc+PUsTGXTrkr1BDvFqsu5r49mSlgI5evwrk9eutA==}
@@ -7209,6 +7218,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -12358,6 +12371,8 @@ snapshots:
'@types/prop-types@15.7.5': {}
'@types/qs@6.15.1': {}
'@types/react-addons-update@0.14.21':
dependencies:
'@types/react': 18.0.26
@@ -16686,6 +16701,10 @@ snapshots:
dependencies:
react: 18.2.0
qs@6.15.2:
dependencies:
side-channel: 1.1.0
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}

View File

@@ -6,6 +6,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
});
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
applySerializedParams(serialize(preparedQuery), urlParams);
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,6 +32,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -252,7 +253,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
...serializeToParams(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

@@ -18,7 +18,6 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -6,6 +6,10 @@ import {
import { SelectOption } from 'types/common/select';
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
{
value: MetricAggregateOperator.NOOP,
label: 'No aggregation',
},
{
value: MetricAggregateOperator.COUNT,
label: 'Count',

View File

@@ -1,5 +1,7 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getAutoContexts } from '../getAutoContexts';
@@ -147,4 +149,24 @@ describe('getAutoContexts', () => {
),
).toStrictEqual([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

View File

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { serialize } from 'lib/compositeQuery/serializer';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,6 +8,7 @@ import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -218,7 +219,9 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,6 +2,10 @@ import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
@@ -75,10 +79,7 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,6 +1,7 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
@@ -339,15 +340,9 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
};
history.push(

View File

@@ -62,6 +62,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -174,7 +176,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): string => {
(defaultQuery: Query | null): Record<string, string> => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -184,7 +186,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return JSON.stringify(queryToUse);
return serializeToParams(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -208,14 +210,7 @@ function ExplorerOptions({
);
}
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
return serializeToParams(modifiedQuery);
},
[panelType, query, sourcepage],
);
@@ -238,13 +233,9 @@ function ExplorerOptions({
});
}
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
const serializedParams = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -34,6 +34,7 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -384,7 +385,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -610,7 +611,7 @@ function FormAlertRules({
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,6 +23,10 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -212,9 +216,7 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
...serializeToParams(clonedWidget.query),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -255,7 +257,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,6 +29,10 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -86,10 +90,7 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
applySerializedParams(serialize(widget.query), urlQuery);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,6 +7,10 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -134,10 +138,7 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,6 +28,10 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -410,7 +414,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -435,7 +439,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,6 +53,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -77,6 +78,7 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -896,8 +898,6 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,17 +907,12 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
history.push(URL);
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,5 +1,6 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k1'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k2'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,6 +6,10 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,6 +14,10 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -111,10 +115,7 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), urlQuery);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,16 +247,12 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -8,6 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -60,16 +64,18 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
apmToTraceQuery: Query,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -105,16 +111,12 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -29,13 +30,11 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,6 +33,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -791,9 +792,7 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
...serializeToParams(currentQuery),
[QueryParams.variables]: variables,
};

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -19,6 +19,7 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -139,7 +140,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
...serializeToParams(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -15,6 +15,10 @@ import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
@@ -155,7 +159,7 @@ function SpanRelatedSignals({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -3,6 +3,7 @@ import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { deserialize } from 'lib/compositeQuery/serializer';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
@@ -545,14 +546,13 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
// Verify composite query includes both trace_id and span_id filters
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id
// Note: Current behavior uses only trace_id filter for navigation
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();
@@ -595,16 +595,16 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
// Verify composite query includes only trace_id filter (no span_id for context logs)
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
// Verify composite query filters by trace_id and the context log's own span_id
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id but not span_id for context logs
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
// Context logs should not have span_id filter
expect(filter.expression).not.toContain('span_id');
// Check that the filter expression contains trace_id
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Context logs use their own span id, not the currently selected span id
expect(filter?.expression).toContain("span_id = 'different-span-id'");
expect(filter?.expression).not.toContain('test-span-id');
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();

View File

@@ -35,6 +35,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -278,7 +283,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): string => {
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -299,7 +304,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -334,9 +339,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -424,9 +429,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -0,0 +1,170 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

View File

@@ -5,7 +5,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -13,6 +12,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
const urlQuery = useUrlQuery();
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,9 +2,10 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -79,14 +80,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const getCompositeQueryFromLastOpen = (): Query => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
const composite = deserialize(query);
if (!composite) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
return composite;
};
describe('useCreateAlerts', () => {

View File

@@ -0,0 +1,26 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,6 +14,10 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -86,10 +90,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), params);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,72 +1,10 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return null;
}
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
parsedCompositeQuery = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(() => deserialize(urlQuery), [urlQuery]);
};

View File

@@ -1,6 +1,9 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -18,77 +21,6 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -0,0 +1,103 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -0,0 +1,269 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`baseline immutability snapshots LOGS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots LOGS_BASELINE_V1_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

@@ -0,0 +1,121 @@
/**
* ╔════════════════════════════════════════════════════════════════════════════╗
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
* ╠════════════════════════════════════════════════════════════════════════════╣
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
* ║ ║
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
* ║ diff against these exact baselines. Changing a single byte here silently ║
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
* ║ ║
* ║ If these snapshot tests fail: ║
* ║ 1. DO NOT update the snapshots ║
* ║ 2. REVERT your changes to baseline.ts immediately ║
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
* ║ - Keep the old baselines untouched for backwards compatibility ║
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import getBaselineByTag, { pickBaseline } from '../baseline';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
describe('baseline immutability snapshots', () => {
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('METRICS_BASELINE_V1 must never change', () => {
expect(METRICS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('LOGS_BASELINE_V1 must never change', () => {
expect(LOGS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('TRACES_BASELINE_V1 must never change', () => {
expect(TRACES_BASELINE_V1).toMatchSnapshot();
});
});
describe('pickBaseline', () => {
it('returns metrics baseline for metrics dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'metrics' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('returns logs baseline for logs dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'logs' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(LOGS_BASELINE_V1);
expect(result.tag).toBe('l');
});
it('returns traces baseline for traces dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'traces' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(TRACES_BASELINE_V1);
expect(result.tag).toBe('t');
});
it('defaults to metrics baseline for unknown dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'unknown' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('defaults to metrics baseline when queryData is empty', () => {
const query = {
builder: { queryData: [] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
});
describe('getBaselineByTag', () => {
it('returns LOGS_BASELINE_V1 for tag "l"', () => {
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE_V1);
});
it('returns TRACES_BASELINE_V1 for tag "t"', () => {
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE_V1);
});
it('returns METRICS_BASELINE_V1 for tag "m"', () => {
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE_V1);
});
});

View File

@@ -0,0 +1,51 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -0,0 +1,63 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(query)));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
return migrateLegacyFormat(parsed);
},
};

View File

@@ -0,0 +1,74 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('legacy format compatibility', () => {
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const encoded = params.get(COMPOSITE_QUERY_KEY) ?? '';
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
expect(encoded.startsWith('%7B')).toBe(true);
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(legacy)));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -0,0 +1,81 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter encoding format field aliasing emits the short alias instead of the full field name: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=sum&query0.source="`;
exports[`qsAliasAdapter encoding format prefix substitution rewrites builder.queryData.0 to the query0 prefix: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is independent of source key order: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is stable after spread / reconstruct: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,225 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias leaf codec decodeLeaf falls back to raw text on a malformed tagged token (never throws): decoded-fallback 1`] = `
{
"fallback": "_not json",
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged empty containers: decoded-containers 1`] = `
{
"array": [],
"object": {},
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged scalars back to their type: decoded-scalars 1`] = `
{
"false": false,
"negative": -4.5,
"null": null,
"number": 123,
"true": true,
}
`;
exports[`qsAlias leaf codec decodeLeaf returns untagged tokens as plain strings: decoded-strings 1`] = `
{
"123": "123",
"empty": "",
"null": "null",
"traces": "traces",
"true": "true",
}
`;
exports[`qsAlias leaf codec decodeLeaf unescapes a doubled-tag string: decoded-escaped 1`] = `
{
"__": "_",
"___name__": "__name__",
"__x": "_x",
}
`;
exports[`qsAlias leaf codec encodeLeaf emits strings verbatim: encoded-strings 1`] = `
{
"empty": "",
"service.name": "service.name",
"traces": "traces",
}
`;
exports[`qsAlias leaf codec encodeLeaf escapes a string that begins with the tag char by doubling it: encoded-escaped 1`] = `
{
"_": "__",
"__name__": "___name__",
"_x": "__x",
}
`;
exports[`qsAlias leaf codec encodeLeaf normalizes undefined to null: encoded-undefined 1`] = `
{
"undefined": "_null",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags empty containers: encoded-containers 1`] = `
{
"array": "_[]",
"object": "_{}",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags non-string scalars with a leading underscore: encoded-scalars 1`] = `
{
"false": "_false",
"negative": "_-4.5",
"null": "_null",
"number": "_123",
"true": "_true",
}
`;
exports[`qsAlias leaf codec round-trip "" survives encode → decode: roundtrip-"" 1`] = `
{
"decoded": "",
"encoded": "",
"input": "",
}
`;
exports[`qsAlias leaf codec round-trip "_" survives encode → decode: roundtrip-"_" 1`] = `
{
"decoded": "_",
"encoded": "__",
"input": "_",
}
`;
exports[`qsAlias leaf codec round-trip "_leading" survives encode → decode: roundtrip-"_leading" 1`] = `
{
"decoded": "_leading",
"encoded": "__leading",
"input": "_leading",
}
`;
exports[`qsAlias leaf codec round-trip "123" survives encode → decode: roundtrip-"123" 1`] = `
{
"decoded": "123",
"encoded": "123",
"input": "123",
}
`;
exports[`qsAlias leaf codec round-trip "a=b&c#d%e+f.g" survives encode → decode: roundtrip-"a=b&c#d%e+f.g" 1`] = `
{
"decoded": "a=b&c#d%e+f.g",
"encoded": "a=b&c#d%e+f.g",
"input": "a=b&c#d%e+f.g",
}
`;
exports[`qsAlias leaf codec round-trip "false" survives encode → decode: roundtrip-"false" 1`] = `
{
"decoded": "false",
"encoded": "false",
"input": "false",
}
`;
exports[`qsAlias leaf codec round-trip "null" survives encode → decode: roundtrip-"null" 1`] = `
{
"decoded": "null",
"encoded": "null",
"input": "null",
}
`;
exports[`qsAlias leaf codec round-trip "service.name" survives encode → decode: roundtrip-"service.name" 1`] = `
{
"decoded": "service.name",
"encoded": "service.name",
"input": "service.name",
}
`;
exports[`qsAlias leaf codec round-trip "traces" survives encode → decode: roundtrip-"traces" 1`] = `
{
"decoded": "traces",
"encoded": "traces",
"input": "traces",
}
`;
exports[`qsAlias leaf codec round-trip "true" survives encode → decode: roundtrip-"true" 1`] = `
{
"decoded": "true",
"encoded": "true",
"input": "true",
}
`;
exports[`qsAlias leaf codec round-trip [] survives encode → decode: roundtrip-[] 1`] = `
{
"decoded": [],
"encoded": "_[]",
"input": [],
}
`;
exports[`qsAlias leaf codec round-trip {} survives encode → decode: roundtrip-{} 1`] = `
{
"decoded": {},
"encoded": "_{}",
"input": {},
}
`;
exports[`qsAlias leaf codec round-trip -4.5 survives encode → decode: roundtrip--4.5 1`] = `
{
"decoded": -4.5,
"encoded": "_-4.5",
"input": -4.5,
}
`;
exports[`qsAlias leaf codec round-trip 0 survives encode → decode: roundtrip-0 1`] = `
{
"decoded": 0,
"encoded": "_0",
"input": 0,
}
`;
exports[`qsAlias leaf codec round-trip 123 survives encode → decode: roundtrip-123 1`] = `
{
"decoded": 123,
"encoded": "_123",
"input": 123,
}
`;
exports[`qsAlias leaf codec round-trip false survives encode → decode: roundtrip-false 1`] = `
{
"decoded": false,
"encoded": "_false",
"input": false,
}
`;
exports[`qsAlias leaf codec round-trip null survives encode → decode: roundtrip-null 1`] = `
{
"decoded": null,
"encoded": "_null",
"input": null,
}
`;
exports[`qsAlias leaf codec round-trip true survives encode → decode: roundtrip-true 1`] = `
{
"decoded": true,
"encoded": "_true",
"input": true,
}
`;

View File

@@ -0,0 +1,388 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias maps FIELD_ALIASES integrity FIELD_REVERSE is the exact inverse of FIELD_ALIASES: all-reverse 1`] = `
{
"aggAttr": "aggregateAttribute",
"aggOp": "aggregateOperator",
"ds": "dataSource",
"dt": "dataType",
"ic": "isColumn",
"ij": "isJSON",
"mn": "metricName",
"qn": "queryName",
"qt": "queryType",
"spaceAgg": "spaceAggregation",
"stepIn": "stepInterval",
"timeAgg": "timeAggregation",
"tp": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES integrity alias values are unique (no two fields share an alias): all-aliases 1`] = `
{
"aggregateAttribute": "aggAttr",
"aggregateOperator": "aggOp",
"dataSource": "ds",
"dataType": "dt",
"isColumn": "ic",
"isJSON": "ij",
"metricName": "mn",
"queryName": "qn",
"queryType": "qt",
"spaceAggregation": "spaceAgg",
"stepInterval": "stepIn",
"temporality": "tp",
"timeAggregation": "timeAgg",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateAttribute ⇄ aggAttr via aliasField / expandField: alias-aggregateAttribute 1`] = `
{
"alias": "aggAttr",
"aliased": "aggAttr",
"expanded": "aggregateAttribute",
"field": "aggregateAttribute",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateOperator ⇄ aggOp via aliasField / expandField: alias-aggregateOperator 1`] = `
{
"alias": "aggOp",
"aliased": "aggOp",
"expanded": "aggregateOperator",
"field": "aggregateOperator",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataSource ⇄ ds via aliasField / expandField: alias-dataSource 1`] = `
{
"alias": "ds",
"aliased": "ds",
"expanded": "dataSource",
"field": "dataSource",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataType ⇄ dt via aliasField / expandField: alias-dataType 1`] = `
{
"alias": "dt",
"aliased": "dt",
"expanded": "dataType",
"field": "dataType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isColumn ⇄ ic via aliasField / expandField: alias-isColumn 1`] = `
{
"alias": "ic",
"aliased": "ic",
"expanded": "isColumn",
"field": "isColumn",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isJSON ⇄ ij via aliasField / expandField: alias-isJSON 1`] = `
{
"alias": "ij",
"aliased": "ij",
"expanded": "isJSON",
"field": "isJSON",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips metricName ⇄ mn via aliasField / expandField: alias-metricName 1`] = `
{
"alias": "mn",
"aliased": "mn",
"expanded": "metricName",
"field": "metricName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryName ⇄ qn via aliasField / expandField: alias-queryName 1`] = `
{
"alias": "qn",
"aliased": "qn",
"expanded": "queryName",
"field": "queryName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryType ⇄ qt via aliasField / expandField: alias-queryType 1`] = `
{
"alias": "qt",
"aliased": "qt",
"expanded": "queryType",
"field": "queryType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips spaceAggregation ⇄ spaceAgg via aliasField / expandField: alias-spaceAggregation 1`] = `
{
"alias": "spaceAgg",
"aliased": "spaceAgg",
"expanded": "spaceAggregation",
"field": "spaceAggregation",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips stepInterval ⇄ stepIn via aliasField / expandField: alias-stepInterval 1`] = `
{
"alias": "stepIn",
"aliased": "stepIn",
"expanded": "stepInterval",
"field": "stepInterval",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips temporality ⇄ tp via aliasField / expandField: alias-temporality 1`] = `
{
"alias": "tp",
"aliased": "tp",
"expanded": "temporality",
"field": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips timeAggregation ⇄ timeAgg via aliasField / expandField: alias-timeAggregation 1`] = `
{
"alias": "timeAgg",
"aliased": "timeAgg",
"expanded": "timeAggregation",
"field": "timeAggregation",
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips chsql ⇄ [["clickhouse_sql"]] via transformPath / expandPath: prefix-chsql 1`] = `
{
"expanded": [
"clickhouse_sql",
0,
"someField",
],
"match": [
"clickhouse_sql",
],
"prefix": "chsql",
"transformed": [
"chsql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips formula ⇄ [["builder", "queryFormulas"]] via transformPath / expandPath: prefix-formula 1`] = `
{
"expanded": [
"builder",
"queryFormulas",
0,
"someField",
],
"match": [
"builder",
"queryFormulas",
],
"prefix": "formula",
"transformed": [
"formula0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips handles multi-digit indices: multi-digit 1`] = `
{
"expanded": [
"builder",
"queryData",
12,
"x",
],
"prefix": "query",
"transformed": [
"query12",
"x",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips promql ⇄ [["promql"]] via transformPath / expandPath: prefix-promql 1`] = `
{
"expanded": [
"promql",
0,
"someField",
],
"match": [
"promql",
],
"prefix": "promql",
"transformed": [
"promql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips query ⇄ [["builder", "queryData"]] via transformPath / expandPath: prefix-query 1`] = `
{
"expanded": [
"builder",
"queryData",
0,
"someField",
],
"match": [
"builder",
"queryData",
],
"prefix": "query",
"transformed": [
"query0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips traceOp ⇄ [["builder", "queryTraceOperator"]] via transformPath / expandPath: prefix-traceOp 1`] = `
{
"expanded": [
"builder",
"queryTraceOperator",
0,
"someField",
],
"match": [
"builder",
"queryTraceOperator",
],
"prefix": "traceOp",
"transformed": [
"traceOp0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_REVERSE consistency mirrors PREFIX_PATTERNS one-to-one: all-prefix-reverse 1`] = `
{
"chsql": [
"clickhouse_sql",
],
"formula": [
"builder",
"queryFormulas",
],
"promql": [
"promql",
],
"query": [
"builder",
"queryData",
],
"traceOp": [
"builder",
"queryTraceOperator",
],
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric path segments untouched: numeric-passthrough 1`] = `
{
"seven": 7,
"zero": 0,
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric-string segments untouched in expandField: numeric-string-passthrough 1`] = `
{
"fortyTwo": "42",
"zero": "0",
}
`;
exports[`qsAlias maps alias / expand passthrough leaves unknown field names untouched: unknown-passthrough 1`] = `
{
"aliasUnknown": "unknownField",
"expandUnknown": "zz",
}
`;
exports[`qsAlias maps isOwnedKey matches chsql prefix with index: owned-chsql 1`] = `
{
"chsql0": true,
"chsql0.field": true,
"chsql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches delete-prefixed keys: delete-prefixed 1`] = `
{
"-formula0": true,
"-query0.field": true,
}
`;
exports[`qsAlias maps isOwnedKey matches formula prefix with index: owned-formula 1`] = `
{
"formula0": true,
"formula0.field": true,
"formula12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches promql prefix with index: owned-promql 1`] = `
{
"promql0": true,
"promql0.field": true,
"promql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches query prefix with index: owned-query 1`] = `
{
"query0": true,
"query0.field": true,
"query12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches the tag key: tag-key 1`] = `
{
"_t": true,
}
`;
exports[`qsAlias maps isOwnedKey matches top-level query keys: top-level-keys 1`] = `
{
"id": true,
"qt": true,
"queryType": true,
"unit": true,
}
`;
exports[`qsAlias maps isOwnedKey matches traceOp prefix with index: owned-traceOp 1`] = `
{
"traceOp0": true,
"traceOp0.field": true,
"traceOp12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey rejects foreign params: foreign-params 1`] = `
{
"compositeQuery": false,
"endTime": false,
"panelTypes": false,
"startTime": false,
}
`;
exports[`qsAlias maps isOwnedKey rejects prefix without index: prefix-without-index 1`] = `
{
"formula": false,
"query": false,
}
`;

View File

@@ -0,0 +1,795 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "SELECT count() FROM signoz_logs",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "clickhouse_sql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-url 1`] = `"_t=QAm&chsql0.query=SELECT+count%28%29+FROM+signoz_logs&id=test-stable-id&qt=clickhouse_sql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "sum",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.legend=sum&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "p95",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": true,
"expression": "A",
"filter": {
"expression": "severity_text = 'ERROR'",
},
"filters": {
"items": [
{
"id": "item-1",
"key": {
"dataType": "string",
"isColumn": false,
"isJSON": false,
"key": "severity_text",
"type": "tag",
},
"op": "=",
"value": "ERROR",
},
],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "error rate",
"limit": null,
"orderBy": [
{
"columnName": "timestamp",
"order": "desc",
},
],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": 60,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=p95&query0.disabled=_true&query0.filter.expression=severity_text+%3D+%27ERROR%27&query0.filters.items.0.id=item-1&query0.filters.items.0.key.dt=string&query0.filters.items.0.key.ic=_false&query0.filters.items.0.key.ij=_false&query0.filters.items.0.key.key=severity_text&query0.filters.items.0.key.type=tag&query0.filters.items.0.op=%3D&query0.filters.items.0.value=ERROR&query0.legend=error+rate&query0.orderBy.0.columnName=timestamp&query0.orderBy.0.order=desc&query0.source=&query0.stepIn=_60"`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "rate(http_requests_total[5m])",
},
],
"queryType": "promql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-url 1`] = `"_t=QAm&id=test-stable-id&promql0.query=rate%28http_requests_total%5B5m%5D%29&qt=promql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "!weird = "x_y*z"",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "_a*b_*c",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.filter.expression=%21weird+%3D+%22x_y*z%22&query0.legend=__a*b_*c&query0.source="`;

View File

@@ -0,0 +1,277 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter tagging encode tags by dataSource logs → QAl: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource metrics → QAm: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource traces → QAt: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAl decodes to the logs baseline: decoded-QAl 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAm decodes to the metrics baseline: decoded-QAm 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAt decodes to the traces baseline: decoded-QAt 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,213 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const makeFilterItem = (value: string): any => ({
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
},
id: `item-${value}`,
op: '=',
value,
});
describe('qsAliasAdapter edge cases', () => {
describe('baseline field deletion', () => {
it('emits a delete token and decode drops the field', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (query.builder.queryData[0] as any).aggregateOperator;
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('-query0.aggOp');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect('aggregateOperator' in decoded.builder.queryData[0]).toBe(false);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('array growth', () => {
it('round-trips multiple added filter items element-wise', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filters = {
op: 'AND',
items: [makeFilterItem('a'), makeFilterItem('b')],
};
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.filters.items.0.');
expect(wire).toContain('query0.filters.items.1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('null and empty containers', () => {
it('round-trips a null leaf', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).legend = null;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-object leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filter =
{} as Query['builder']['queryData'][0]['filter'];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-array leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].groupBy = [];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('undefined values', () => {
it('does not break decode when fields are undefined', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
expect(() => roundTrip(query)).not.toThrow();
const decoded = roundTrip(query);
expect(decoded).not.toBeNull();
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
/**
* The wire type-tags non-strings (`_123`, `_true`, `_null`) and emits strings
* verbatim, while qs percent-encodes values. Every scalar therefore
* round-trips losslessly — including strings that look like numbers/booleans
* or contain query-string delimiters.
*/
describe('tricky scalar values (lossless)', () => {
it('keeps a numeric-looking string as a string', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '123';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps "true" / "false" / "null" string values as strings', () => {
['true', 'false', 'null'].forEach((literal) => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = literal;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`url-${literal}`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${literal}`);
});
});
it('preserves a value containing the ampersand delimiter', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'x&y';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves assorted wire-special characters', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'a=b&c#d%e+f.g';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves a string that begins with the type-tag char', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_underscored';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('scalar type fidelity', () => {
it('keeps number and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].stepInterval = 300;
query.builder.queryData[0].legend = '300';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].stepInterval).toBe(300);
expect(decoded.builder.queryData[0].legend).toBe('300');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps boolean and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].disabled = true;
query.builder.queryData[0].legend = 'true';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].disabled).toBe(true);
expect(decoded.builder.queryData[0].legend).toBe('true');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,89 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
describe('qsAliasAdapter encoding format', () => {
describe('prefix substitution', () => {
it('rewrites builder.queryData.0 to the query0 prefix', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData[0].aggregateOperator = 'count';
const encoded = qsAliasAdapter.encode(query);
const keys = Array.from(encoded.keys());
expect(keys.some((k) => k.startsWith('query0.'))).toBe(true);
expect(keys.some((k) => k.includes('queryData'))).toBe(false);
expect(keys.some((k) => k.includes('builder'))).toBe(false);
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('field aliasing', () => {
it('emits the short alias instead of the full field name', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].aggregateOperator = 'sum';
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.aggOp=');
expect(wire).not.toContain('aggregateOperator');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('stability', () => {
it('re-encoding after a decode is byte-identical', () => {
const encoded1 = qsAliasAdapter.encode(initialQueriesMap.metrics);
const encoded2 = qsAliasAdapter.encode(qsAliasAdapter.decode(encoded1));
expect(encoded2.toString()).toBe(encoded1.toString());
expect(normalizeUrl(encoded1.toString())).toMatchSnapshot('url');
expect(normalizeId(qsAliasAdapter.decode(encoded1))).toMatchSnapshot(
'decoded',
);
});
it('is independent of source key order', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const wire1 = qsAliasAdapter.encode(query1).toString();
const wire2 = qsAliasAdapter.encode(reordered).toString();
expect(wire2).toBe(wire1);
expect(normalizeUrl(wire1)).toMatchSnapshot('url');
});
it('is stable after spread / reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({ ...item })),
},
};
const wire = qsAliasAdapter.encode(transformed).toString();
expect(wire).toBe(qsAliasAdapter.encode(query).toString());
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
});

View File

@@ -0,0 +1,154 @@
import { Json } from '../diff/predicates';
import { decodeLeaf, encodeLeaf } from '../leaf';
describe('qsAlias leaf codec', () => {
describe('encodeLeaf', () => {
it('emits strings verbatim', () => {
expect(encodeLeaf('traces')).toBe('traces');
expect(encodeLeaf('service.name')).toBe('service.name');
expect(encodeLeaf('')).toBe('');
expect({
traces: encodeLeaf('traces'),
'service.name': encodeLeaf('service.name'),
empty: encodeLeaf(''),
}).toMatchSnapshot('encoded-strings');
});
it('type-tags non-string scalars with a leading underscore', () => {
expect(encodeLeaf(123)).toBe('_123');
expect(encodeLeaf(-4.5)).toBe('_-4.5');
expect(encodeLeaf(true)).toBe('_true');
expect(encodeLeaf(false)).toBe('_false');
expect(encodeLeaf(null)).toBe('_null');
expect({
number: encodeLeaf(123),
negative: encodeLeaf(-4.5),
true: encodeLeaf(true),
false: encodeLeaf(false),
null: encodeLeaf(null),
}).toMatchSnapshot('encoded-scalars');
});
it('type-tags empty containers', () => {
expect(encodeLeaf([])).toBe('_[]');
expect(encodeLeaf({})).toBe('_{}');
expect({
array: encodeLeaf([]),
object: encodeLeaf({}),
}).toMatchSnapshot('encoded-containers');
});
it('normalizes undefined to null', () => {
expect(encodeLeaf(undefined)).toBe('_null');
expect({ undefined: encodeLeaf(undefined) }).toMatchSnapshot(
'encoded-undefined',
);
});
it('escapes a string that begins with the tag char by doubling it', () => {
expect(encodeLeaf('_x')).toBe('__x');
expect(encodeLeaf('_')).toBe('__');
expect(encodeLeaf('__name__')).toBe('___name__');
expect({
_x: encodeLeaf('_x'),
_: encodeLeaf('_'),
__name__: encodeLeaf('__name__'),
}).toMatchSnapshot('encoded-escaped');
});
});
describe('decodeLeaf', () => {
it('returns untagged tokens as plain strings', () => {
expect(decodeLeaf('traces')).toBe('traces');
expect(decodeLeaf('123')).toBe('123');
expect(decodeLeaf('true')).toBe('true');
expect(decodeLeaf('null')).toBe('null');
expect(decodeLeaf('')).toBe('');
expect({
traces: decodeLeaf('traces'),
'123': decodeLeaf('123'),
true: decodeLeaf('true'),
null: decodeLeaf('null'),
empty: decodeLeaf(''),
}).toMatchSnapshot('decoded-strings');
});
it('parses tagged scalars back to their type', () => {
expect(decodeLeaf('_123')).toBe(123);
expect(decodeLeaf('_-4.5')).toBe(-4.5);
expect(decodeLeaf('_true')).toBe(true);
expect(decodeLeaf('_false')).toBe(false);
expect(decodeLeaf('_null')).toBeNull();
expect({
number: decodeLeaf('_123'),
negative: decodeLeaf('_-4.5'),
true: decodeLeaf('_true'),
false: decodeLeaf('_false'),
null: decodeLeaf('_null'),
}).toMatchSnapshot('decoded-scalars');
});
it('parses tagged empty containers', () => {
expect(decodeLeaf('_[]')).toStrictEqual([]);
expect(decodeLeaf('_{}')).toStrictEqual({});
expect({
array: decodeLeaf('_[]'),
object: decodeLeaf('_{}'),
}).toMatchSnapshot('decoded-containers');
});
it('unescapes a doubled-tag string', () => {
expect(decodeLeaf('__x')).toBe('_x');
expect(decodeLeaf('__')).toBe('_');
expect(decodeLeaf('___name__')).toBe('__name__');
expect({
__x: decodeLeaf('__x'),
__: decodeLeaf('__'),
___name__: decodeLeaf('___name__'),
}).toMatchSnapshot('decoded-escaped');
});
it('falls back to raw text on a malformed tagged token (never throws)', () => {
expect(() => decodeLeaf('_not json')).not.toThrow();
expect(decodeLeaf('_not json')).toBe('_not json');
expect({ fallback: decodeLeaf('_not json') }).toMatchSnapshot(
'decoded-fallback',
);
});
});
describe('round-trip', () => {
const cases: Json[] = [
'traces',
'',
'123',
'true',
'false',
'null',
'_leading',
'_',
'a=b&c#d%e+f.g',
'service.name',
0,
123,
-4.5,
true,
false,
null,
[],
{},
];
it.each(cases.map((value) => [JSON.stringify(value), value] as const))(
'%s survives encode → decode',
(label, value) => {
const encoded = encodeLeaf(value);
const decoded = decodeLeaf(encoded);
expect(decoded).toStrictEqual(value);
expect({ input: value, encoded, decoded }).toMatchSnapshot(
`roundtrip-${label}`,
);
},
);
});
});

View File

@@ -0,0 +1,179 @@
import { aliasField, expandField, expandPath, transformPath } from '../codec';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from '../maps';
describe('qsAlias maps', () => {
describe('FIELD_ALIASES — every key round-trips', () => {
it.each(Object.entries(FIELD_ALIASES))(
'%s ⇄ %s via aliasField / expandField',
(field, alias) => {
expect(aliasField(field)).toBe(alias);
expect(expandField(alias)).toBe(field);
expect({
field,
alias,
aliased: aliasField(field),
expanded: expandField(alias),
}).toMatchSnapshot(`alias-${field}`);
},
);
});
describe('FIELD_ALIASES integrity', () => {
it('alias values are unique (no two fields share an alias)', () => {
const values = Object.values(FIELD_ALIASES);
expect(new Set(values).size).toBe(values.length);
expect(FIELD_ALIASES).toMatchSnapshot('all-aliases');
});
it('no alias contains "." (would corrupt path splitting)', () => {
Object.values(FIELD_ALIASES).forEach((alias) => {
expect(alias).not.toContain('.');
});
});
it('FIELD_REVERSE is the exact inverse of FIELD_ALIASES', () => {
expect(FIELD_REVERSE).toStrictEqual(
Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([key, value]) => [value, key]),
),
);
expect(FIELD_REVERSE).toMatchSnapshot('all-reverse');
});
});
describe('PREFIX_PATTERNS — every prefix round-trips', () => {
it.each(PREFIX_PATTERNS)(
'$prefix ⇄ [$match] via transformPath / expandPath',
({ match, prefix }) => {
const fullPath = [...match, 0, 'someField'];
const transformed = transformPath(fullPath);
const expanded = expandPath(`${prefix}0.someField`);
expect(transformed).toStrictEqual([`${prefix}0`, 'someField']);
expect(expanded).toStrictEqual([...match, 0, 'someField']);
expect({ prefix, match, transformed, expanded }).toMatchSnapshot(
`prefix-${prefix}`,
);
},
);
it('handles multi-digit indices', () => {
const { match, prefix } = PREFIX_PATTERNS[0];
const transformed = transformPath([...match, 12, 'x']);
const expanded = expandPath(`${prefix}12.x`);
expect(transformed).toStrictEqual([`${prefix}12`, 'x']);
expect(expanded).toStrictEqual([...match, 12, 'x']);
expect({ prefix, transformed, expanded }).toMatchSnapshot('multi-digit');
});
});
describe('PREFIX_REVERSE consistency', () => {
it('mirrors PREFIX_PATTERNS one-to-one', () => {
PREFIX_PATTERNS.forEach(({ match, prefix }) => {
expect(PREFIX_REVERSE[prefix]).toStrictEqual(match);
});
expect(Object.keys(PREFIX_REVERSE).sort()).toStrictEqual(
PREFIX_PATTERNS.map((pattern) => pattern.prefix).sort(),
);
expect(PREFIX_REVERSE).toMatchSnapshot('all-prefix-reverse');
});
});
describe('alias / expand passthrough', () => {
it('leaves numeric path segments untouched', () => {
expect(aliasField(0)).toBe(0);
expect(aliasField(7)).toBe(7);
expect({ zero: aliasField(0), seven: aliasField(7) }).toMatchSnapshot(
'numeric-passthrough',
);
});
it('leaves unknown field names untouched', () => {
expect(aliasField('unknownField')).toBe('unknownField');
expect(expandField('zz')).toBe('zz');
expect({
aliasUnknown: aliasField('unknownField'),
expandUnknown: expandField('zz'),
}).toMatchSnapshot('unknown-passthrough');
});
it('leaves numeric-string segments untouched in expandField', () => {
expect(expandField('0')).toBe('0');
expect(expandField('42')).toBe('42');
expect({
zero: expandField('0'),
fortyTwo: expandField('42'),
}).toMatchSnapshot('numeric-string-passthrough');
});
});
describe('isOwnedKey', () => {
it('matches the tag key', () => {
expect(isOwnedKey('_t')).toBe(true);
expect({ _t: isOwnedKey('_t') }).toMatchSnapshot('tag-key');
});
it.each(PREFIX_PATTERNS.map((p) => p.prefix))(
'matches %s prefix with index',
(prefix) => {
expect(isOwnedKey(`${prefix}0`)).toBe(true);
expect(isOwnedKey(`${prefix}0.field`)).toBe(true);
expect(isOwnedKey(`${prefix}12.nested.path`)).toBe(true);
expect({
[`${prefix}0`]: isOwnedKey(`${prefix}0`),
[`${prefix}0.field`]: isOwnedKey(`${prefix}0.field`),
[`${prefix}12.nested.path`]: isOwnedKey(`${prefix}12.nested.path`),
}).toMatchSnapshot(`owned-${prefix}`);
},
);
it('matches delete-prefixed keys', () => {
expect(isOwnedKey('-query0.field')).toBe(true);
expect(isOwnedKey('-formula0')).toBe(true);
expect({
'-query0.field': isOwnedKey('-query0.field'),
'-formula0': isOwnedKey('-formula0'),
}).toMatchSnapshot('delete-prefixed');
});
it('matches top-level query keys', () => {
expect(isOwnedKey('id')).toBe(true);
expect(isOwnedKey('queryType')).toBe(true);
expect(isOwnedKey('qt')).toBe(true);
expect(isOwnedKey('unit')).toBe(true);
expect({
id: isOwnedKey('id'),
queryType: isOwnedKey('queryType'),
qt: isOwnedKey('qt'),
unit: isOwnedKey('unit'),
}).toMatchSnapshot('top-level-keys');
});
it('rejects foreign params', () => {
expect(isOwnedKey('panelTypes')).toBe(false);
expect(isOwnedKey('startTime')).toBe(false);
expect(isOwnedKey('endTime')).toBe(false);
expect(isOwnedKey('compositeQuery')).toBe(false);
expect({
panelTypes: isOwnedKey('panelTypes'),
startTime: isOwnedKey('startTime'),
endTime: isOwnedKey('endTime'),
compositeQuery: isOwnedKey('compositeQuery'),
}).toMatchSnapshot('foreign-params');
});
it('rejects prefix without index', () => {
expect(isOwnedKey('query')).toBe(false);
expect(isOwnedKey('formula')).toBe(false);
expect({
query: isOwnedKey('query'),
formula: isOwnedKey('formula'),
}).toMatchSnapshot('prefix-without-index');
});
});
});

View File

@@ -0,0 +1,364 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
const makeSecondBuilderQuery = (name: string): IBuilderQuery => ({
...clone(initialQueryBuilderFormValuesMap.metrics),
queryName: name,
aggregateOperator: 'avg',
legend: `${name} legend`,
});
const makeFormula = (name: string, expression: string): IBuilderFormula => ({
queryName: name,
expression,
disabled: false,
legend: `${name} result`,
});
describe('qsAliasAdapter multi-queryData', () => {
describe('multiple builder queries', () => {
it('round-trips two queryData entries (A + B)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three queryData entries (A + B + C)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('formula queries', () => {
it('round-trips single formula F1 = A/B', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips multiple formulas F1 + F2', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
query.builder.queryFormulas.push(makeFormula('F2', 'A*100/C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips formula with complex expression', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', '(A - B) / B * 100'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple clickhouse queries', () => {
it('round-trips two clickhouse_sql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query =
'SELECT count() FROM logs WHERE severity > 0';
query.clickhouse_sql.push({
name: 'B',
legend: 'total',
disabled: false,
query: 'SELECT count() FROM logs',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three clickhouse_sql entries with mixed disabled states', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT 1';
query.clickhouse_sql.push({
name: 'B',
legend: 'second',
disabled: true,
query: 'SELECT 2',
});
query.clickhouse_sql.push({
name: 'C',
legend: '',
disabled: false,
query: 'SELECT 3',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple promql queries', () => {
it('round-trips two promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
query.promql.push({
name: 'B',
legend: 'errors',
disabled: false,
query: 'rate(http_errors_total[5m])',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'metric_a';
query.promql.push({
name: 'B',
legend: 'b-legend',
disabled: false,
query: 'metric_b',
});
query.promql.push({
name: 'C',
legend: '',
disabled: true,
query: 'metric_c',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('mixed data sources within builder', () => {
it('round-trips logs queryData with formulas', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.logs),
queryName: 'B',
aggregateOperator: 'count_distinct',
});
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips traces queryData with formulas', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.traces),
queryName: 'B',
aggregateOperator: 'p99',
});
query.builder.queryFormulas.push(makeFormula('F1', 'B - A'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('wire format verification', () => {
it('encodes multiple queryData with indexed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.');
expect(wire).toContain('query1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes formulas with formula-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(query.builder.queryFormulas).toHaveLength(1);
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
expect(wire).toContain('formula0.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes clickhouse with chsql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql.push({
name: 'B',
legend: '',
disabled: false,
query: 'SELECT 1',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('chsql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes promql with promql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql.push({
name: 'B',
legend: '',
disabled: false,
query: 'metric_b',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('promql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('template diffing optimization', () => {
it('added queryData only emits changed fields vs baseline[0]', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
legend: 'B query',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const query1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('query1.'),
);
// Should have ~4-5 params (qn, aggOp, legend, source), not ~25
expect(query1Params.length).toBeLessThan(10);
// Should NOT have unchanged fields
expect(wire).not.toContain('query1.filters.op');
expect(wire).not.toContain('query1.groupBy');
expect(wire).not.toContain('query1.having');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('decoder correctly reconstructs from template-diffed wire', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
// Wire should be compact
expect(wire).not.toContain('query1.filters.op');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('works for queryFormulas with template inheritance', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryFormulas.push(makeFormula('F1', 'A'));
query.builder.queryFormulas.push({
...makeFormula('F2', 'B'),
disabled: true,
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const f1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula0.'),
);
const f2Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula1.'),
);
// F2 should be smaller or equal (diffs against F1)
expect(f2Params.length).toBeLessThanOrEqual(f1Params.length);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,52 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../../testing/scenarios';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
describe('qsAliasAdapter round-trip', () => {
describe('scenarios', () => {
it.each(roundTripScenarios)(
'$name survives encode → decode',
({ query, name }) => {
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`${name}-url`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`${name}-decoded`);
},
);
});
it('decoded query keeps exactly the source top-level keys', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
expect(Object.keys(decoded).sort()).toStrictEqual(
Object.keys(initialQueriesMap.metrics).sort(),
);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('is lodash isEqual to the source (ignoring volatile id)', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
const { id: _sourceId, ...source } = initialQueriesMap.metrics;
const { id: _decodedId, ...result } = decoded;
expect(isEqual(source, result)).toBe(true);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});

View File

@@ -0,0 +1,92 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { decodeQsAlias, encodeQsAlias, qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const tagOf = (params: URLSearchParams): string => params.get('_t') ?? '';
describe('qsAliasAdapter tagging', () => {
describe('encode tags by dataSource', () => {
it('metrics → QAm', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.metrics);
expect(tagOf(encoded)).toBe('QAm');
expect(encodeQsAlias(initialQueriesMap.metrics).tag).toBe('QAm');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('logs → QAl', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.logs);
expect(tagOf(encoded)).toBe('QAl');
expect(encodeQsAlias(initialQueriesMap.logs).tag).toBe('QAl');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('traces → QAt', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.traces);
expect(tagOf(encoded)).toBe('QAt');
expect(encodeQsAlias(initialQueriesMap.traces).tag).toBe('QAt');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('matches', () => {
it('matches its own QAm/QAl/QAt tags', () => {
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.metrics)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.logs)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.traces)),
).toBe(true);
});
it('rejects another serializer tag', () => {
const params = new URLSearchParams();
params.set('_t', 'FVm~');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects the legacy compositeQuery param', () => {
const params = new URLSearchParams();
params.set('compositeQuery', '{"queryType":"builder"}');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects empty params', () => {
expect(qsAliasAdapter.matches(new URLSearchParams())).toBe(false);
});
});
describe('tag-only decode returns the baseline', () => {
it.each([
['QAm', 'metrics'],
['QAl', 'logs'],
['QAt', 'traces'],
] as const)('%s decodes to the %s baseline', (tag, dataSource) => {
const params = new URLSearchParams();
params.set('_t', tag);
const decoded = decodeQsAlias(params);
expect(decoded.queryType).toBe('builder');
expect(decoded.builder.queryData[0].dataSource).toBe(dataSource);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${tag}`);
});
it('round-trips the baseline with no extra params', () => {
const { params, tag } = encodeQsAlias(initialQueriesMap.logs);
expect(tag).toBe('QAl');
expect(normalizeUrl(params.toString())).toMatchSnapshot('url');
const decoded = decodeQsAlias(params);
expect(decoded).toStrictEqual(initialQueriesMap.logs);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,302 @@
/**
* qsAlias codec: content-aware URL serialization with prefix substitution
* and field aliasing for readable, compact URLs.
*
* Wire format: multiple query params with aliased paths
* _t=QAm&query0.ds=traces&query0.aa.key=http.status_code&query0.fl.it.0.key.key=service.name
*
* Prefix substitution: builder.queryData.0 → query0
* Field aliasing: aggregateAttribute → aa, filters → fl, etc.
*/
import set from 'lodash-es/set';
import qs from 'qs';
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { computeDiff, DiffCode } from './diff/diff';
import { isLeaf, Json, PathSeg } from './diff/predicates';
import { decodeLeaf, encodeLeaf } from './leaf';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from './maps';
const TAG_KEY = '_t';
const DEL_PREFIX = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
function matchesPrefix(path: PathSeg[], match: string[]): boolean {
for (let i = 0; i < match.length; i++) {
if (path[i] !== match[i]) {
return false;
}
}
return true;
}
// Path/alias helpers below are exported for direct unit testing; the adapter's
// public surface (index.ts) still exposes only encode/decode.
export function aliasField(seg: PathSeg): PathSeg {
if (typeof seg === 'number') {
return seg;
}
return FIELD_ALIASES[seg] ?? seg;
}
export function expandField(seg: string): string {
if (isIndex(seg)) {
return seg;
}
return FIELD_REVERSE[seg] ?? seg;
}
export function transformPath(path: PathSeg[]): PathSeg[] {
for (const { match, prefix } of PREFIX_PATTERNS) {
if (path.length > match.length && matchesPrefix(path, match)) {
const idx = path[match.length];
if (typeof idx === 'number') {
const rest = path.slice(match.length + 1).map(aliasField);
return [`${prefix}${idx}`, ...rest];
}
}
}
return path.map(aliasField);
}
export function expandPath(pathStr: string): PathSeg[] {
const segs = pathStr.split('.');
const first = segs[0];
for (const [prefixName, originalPath] of Object.entries(PREFIX_REVERSE)) {
const match = first.match(new RegExp(`^${prefixName}(\\d+)$`));
if (match) {
const idx = parseInt(match[1], 10);
const rest = segs.slice(1).map(expandField);
return [...originalPath, idx, ...rest];
}
}
return segs.map((s) => (isIndex(s) ? parseInt(s, 10) : expandField(s)));
}
function flattenValue(
target: Record<string, string>,
prefix: string,
value: Json,
): void {
if (value === null || typeof value !== 'object') {
target[prefix] = encodeLeaf(value);
return;
}
if (Array.isArray(value)) {
if (value.length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (let i = 0; i < value.length; i++) {
flattenValue(target, `${prefix}.${i}`, value[i]);
}
return;
}
const obj = value as Record<string, Json>;
if (Object.keys(obj).length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (const [k, v] of Object.entries(obj)) {
flattenValue(target, `${prefix}.${aliasField(k)}`, v);
}
}
function diffToFlatObject(
baseline: Query,
query: Query,
): Record<string, string> {
const ops = computeDiff(baseline, query);
const obj: Record<string, string> = {};
for (const [code, path, value] of ops) {
const key = transformPath(path).join('.');
if (code === DiffCode.Delete) {
obj[`${DEL_PREFIX}${key}`] = '';
} else if (typeof value === 'object' && value !== null) {
flattenValue(obj, key, value);
} else {
obj[key] = encodeLeaf(value);
}
}
return obj;
}
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
const segs = path.split('.').map((s) => (isIndex(s) ? parseInt(s, 10) : s));
set(root, segs, value);
});
return root;
}
/**
* Clone baseline[0] paths to a higher index for template-based array diffing.
* When encoder emits `query1.aggOp=avg`, decoder needs `builder.queryData.1.*`
* to exist first (cloned from index 0) before applying the patch.
*/
function ensureArrayIndexFromTemplate(
baseMap: Record<string, Json>,
arrayPrefix: string,
targetIndex: number,
): void {
const sourcePrefix = `${arrayPrefix}.0.`;
const targetPrefix = `${arrayPrefix}.${targetIndex}.`;
// Skip if target already has entries (already cloned or from baseline)
const hasTarget = Object.keys(baseMap).some((k) => k.startsWith(targetPrefix));
if (hasTarget) {
return;
}
// Clone all index-0 paths to target index
for (const [path, value] of Object.entries(baseMap)) {
if (path.startsWith(sourcePrefix)) {
const suffix = path.slice(sourcePrefix.length);
baseMap[`${targetPrefix}${suffix}`] = value;
}
}
}
export function encode(query: Query): { params: URLSearchParams; tag: string } {
const { baseline, tag } = pickBaseline(query);
const obj = diffToFlatObject(baseline, query);
// `encodeValuesOnly` percent-encodes values (so `&`, `=`, `%`, … survive)
// while leaving the readable dotted keys untouched.
const queryString = qs.stringify(
{ [TAG_KEY]: `QA${tag}`, ...obj },
{
encodeValuesOnly: true,
sort: (a, b) => a.localeCompare(b),
},
);
return { params: new URLSearchParams(queryString), tag: `QA${tag}` };
}
/**
* When a nested path like `a.b.0.c` is set, any ancestor empty-container entry
* (`a.b` = `[]`) must be removed or `rebuildFromLeaves` order may clobber it.
*/
function deleteAncestorEmptyContainers(
map: Record<string, Json>,
fullPath: string,
): void {
const segs = fullPath.split('.');
for (let i = 1; i < segs.length; i += 1) {
const ancestor = segs.slice(0, i).join('.');
const value = map[ancestor];
if (
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' &&
value !== null &&
Object.keys(value).length === 0)
) {
delete map[ancestor];
}
}
}
/**
* Check if expanded path refers to an array element beyond index 0.
* Returns [arrayPrefix, index] if so, null otherwise.
*/
function detectArrayGrowth(expandedPath: PathSeg[]): [string, number] | null {
for (const { match } of PREFIX_PATTERNS) {
if (expandedPath.length > match.length) {
const matchesPattern = match.every((seg, i) => expandedPath[i] === seg);
if (matchesPattern) {
const idx = expandedPath[match.length];
if (typeof idx === 'number' && idx > 0) {
return [match.join('.'), idx];
}
}
}
}
return null;
}
export function decode(params: URLSearchParams): Query {
const parsed = qs.parse(params.toString()) as Record<string, unknown>;
const tagValue = (parsed[TAG_KEY] as string) ?? '';
const baselineTag = tagValue.slice(2) as BaselineTag;
const baseline = getBaselineByTag(baselineTag);
const baseMap = leafMap(baseline);
const clonedIndices = new Set<string>();
for (const [key, value] of Object.entries(parsed)) {
if (key === TAG_KEY) {
continue;
}
// Skip foreign params (e.g. panelTypes, startTime) that qs.parse included.
if (!isOwnedKey(key)) {
continue;
}
if (key.startsWith(DEL_PREFIX)) {
const expandedPath = expandPath(key.slice(1));
const shortPath = expandedPath.join('.');
for (const basePath of Object.keys(baseMap)) {
if (basePath === shortPath || basePath.startsWith(`${shortPath}.`)) {
delete baseMap[basePath];
}
}
continue;
}
const expandedPath = expandPath(key);
// For paths like builder.queryData.1.*, clone from index 0 first
const growth = detectArrayGrowth(expandedPath);
if (growth) {
const [arrayPrefix, idx] = growth;
const cacheKey = `${arrayPrefix}.${idx}`;
if (!clonedIndices.has(cacheKey)) {
ensureArrayIndexFromTemplate(baseMap, arrayPrefix, idx);
clonedIndices.add(cacheKey);
}
}
const fullPath = expandedPath.join('.');
deleteAncestorEmptyContainers(baseMap, fullPath);
baseMap[fullPath] = typeof value === 'string' ? decodeLeaf(value) : value;
}
return rebuildFromLeaves(baseMap) as unknown as Query;
}

View File

@@ -0,0 +1,342 @@
import {
computeDiff,
DiffCode,
DiffOp,
diffArrays,
diffNodes,
diffObjects,
} from '../diff';
const noop = (): void => undefined;
const paths = (ops: DiffOp[]): string[] =>
ops.map(([, path]) => path.join('.'));
describe('qsAlias/diff', () => {
describe('DiffCode', () => {
it('has stable wire-significant numeric codes', () => {
// These leak onto the URL via the codec, so they must not drift.
expect(DiffCode.Set).toBe(1);
expect(DiffCode.Delete).toBe(2);
});
});
describe('computeDiff on leaves', () => {
it('returns no ops when scalars are equal', () => {
expect(computeDiff('a', 'a')).toStrictEqual([]);
expect(computeDiff(1, 1)).toStrictEqual([]);
expect(computeDiff(true, true)).toStrictEqual([]);
expect(computeDiff(null, null)).toStrictEqual([]);
});
it('emits a single Set rooted at [] when scalars differ', () => {
expect(computeDiff(1, 2)).toStrictEqual([[DiffCode.Set, [], 2]]);
expect(computeDiff('a', 'b')).toStrictEqual([[DiffCode.Set, [], 'b']]);
expect(computeDiff(true, false)).toStrictEqual([[DiffCode.Set, [], false]]);
});
it('distinguishes null, false, 0 and empty string', () => {
expect(computeDiff(null, false)).toStrictEqual([[DiffCode.Set, [], false]]);
expect(computeDiff(0, '')).toStrictEqual([[DiffCode.Set, [], '']]);
expect(computeDiff(0, null)).toStrictEqual([[DiffCode.Set, [], null]]);
});
});
describe('computeDiff on objects', () => {
it('returns no ops for deep-equal objects', () => {
expect(
computeDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }),
).toStrictEqual([]);
});
it('emits Set for an added key', () => {
expect(computeDiff({ a: 1 }, { a: 1, b: 2 })).toStrictEqual([
[DiffCode.Set, ['b'], 2],
]);
});
it('emits Delete (undefined value) for a removed key', () => {
expect(computeDiff({ a: 1, b: 2 }, { a: 1 })).toStrictEqual([
[DiffCode.Delete, ['b'], undefined],
]);
});
it('emits Set at the nested path for a changed deep value', () => {
expect(
computeDiff({ a: { b: { c: 1 } } }, { a: { b: { c: 9 } } }),
).toStrictEqual([[DiffCode.Set, ['a', 'b', 'c'], 9]]);
});
it('produces deterministic op order following base-then-query keys', () => {
const base = { ds: 'logs', ag: [{ mn: 'x', ao: 'noop' }], gb: [] };
const query = {
ds: 'traces',
ag: [{ mn: 'x', ao: 'sum' }, { mn: 'y' }],
gb: [],
};
// Generic arrays use wholesale SET for added elements.
// Template diffing only applies to known query builder arrays.
expect(computeDiff(base, query)).toStrictEqual([
[DiffCode.Set, ['ds'], 'traces'],
[DiffCode.Set, ['ag', 0, 'ao'], 'sum'],
[DiffCode.Set, ['ag', 1], { mn: 'y' }],
]);
});
});
describe('diffArrays', () => {
it('defaults the path to [] and diffs element-wise', () => {
expect(diffArrays([1, 2], [1, 9])).toStrictEqual([[DiffCode.Set, [1], 9]]);
});
it('Sets appended elements at their new index', () => {
expect(diffArrays([1], [1, 2, 3])).toStrictEqual([
[DiffCode.Set, [1], 2],
[DiffCode.Set, [2], 3],
]);
});
it('Deletes trailing elements removed from the query', () => {
expect(diffArrays([1, 2, 3], [1])).toStrictEqual([
[DiffCode.Delete, [1], undefined],
[DiffCode.Delete, [2], undefined],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffArrays([1], [2], ['items'])).toStrictEqual([
[DiffCode.Set, ['items', 0], 2],
]);
});
});
describe('template diffing for query builder arrays', () => {
const baseQuery = { qn: 'A', aggOp: 'count', ds: 'metrics' };
it('uses template for builder.queryData path', () => {
const base = [baseQuery];
const query = [baseQuery, { qn: 'B', aggOp: 'avg', ds: 'metrics' }];
const ops = diffArrays(base, query, ['builder', 'queryData']);
// Should diff query[1] against query[0], not wholesale SET
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryData', 1, 'qn'], 'B'],
[DiffCode.Set, ['builder', 'queryData', 1, 'aggOp'], 'avg'],
]);
});
it('uses template for builder.queryFormulas path', () => {
const baseFormula = { qn: 'F1', expression: 'A', disabled: false };
const base = [baseFormula];
const query = [
baseFormula,
{ qn: 'F2', expression: 'A+B', disabled: false },
];
const ops = diffArrays(base, query, ['builder', 'queryFormulas']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'qn'], 'F2'],
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'expression'], 'A+B'],
]);
});
it('uses template for promql path', () => {
const baseProm = { name: 'A', query: 'up', legend: '', disabled: false };
const base = [baseProm];
const query = [
baseProm,
{ name: 'B', query: 'down', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['promql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['promql', 1, 'name'], 'B'],
[DiffCode.Set, ['promql', 1, 'query'], 'down'],
]);
});
it('uses template for clickhouse_sql path', () => {
const baseCh = { name: 'A', query: 'SELECT 1', legend: '', disabled: false };
const base = [baseCh];
const query = [
baseCh,
{ name: 'B', query: 'SELECT 2', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['clickhouse_sql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['clickhouse_sql', 1, 'name'], 'B'],
[DiffCode.Set, ['clickhouse_sql', 1, 'query'], 'SELECT 2'],
]);
});
it('does NOT use template for unknown paths', () => {
const base = [{ a: 1 }];
const query = [{ a: 1 }, { a: 2 }];
const ops = diffArrays(base, query, ['unknown', 'path']);
// Should emit wholesale SET, not field-level diff
expect(ops).toStrictEqual([
[DiffCode.Set, ['unknown', 'path', 1], { a: 2 }],
]);
});
it('emits DELETE for fields removed vs template', () => {
const base = [{ qn: 'A', aggOp: 'count', extra: 'field' }];
const query = [base[0], { qn: 'B', aggOp: 'avg' }]; // no 'extra'
const ops = diffArrays(base, query, ['builder', 'queryData']);
expect(ops).toContainEqual([
DiffCode.Delete,
['builder', 'queryData', 1, 'extra'],
undefined,
]);
});
});
describe('diffObjects', () => {
it('defaults the path to [] and diffs by own keys', () => {
expect(diffObjects({ a: 1 }, { a: 2 })).toStrictEqual([
[DiffCode.Set, ['a'], 2],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffObjects({ a: 1 }, { a: 2 }, ['root'])).toStrictEqual([
[DiffCode.Set, ['root', 'a'], 2],
]);
});
});
describe('diffNodes shape transitions', () => {
it('replaces a leaf with a container wholesale', () => {
expect(diffNodes('a', { b: 1 })).toStrictEqual([
[DiffCode.Set, [], { b: 1 }],
]);
});
it('replaces a container with a leaf wholesale', () => {
expect(diffNodes({ b: 1 }, 'a')).toStrictEqual([[DiffCode.Set, [], 'a']]);
});
it('walks empty-to-non-empty array element-wise (for prefix substitution)', () => {
expect(diffNodes([], [1])).toStrictEqual([[DiffCode.Set, [0], 1]]);
});
it('emits SET [] when clearing a non-empty array (preserves empty array)', () => {
expect(diffNodes([1], [])).toStrictEqual([[DiffCode.Set, [], []]]);
expect(diffNodes([1, 2, 3], [])).toStrictEqual([[DiffCode.Set, [], []]]);
});
it('diffs array-vs-object key-wise (indices become string keys)', () => {
expect(diffNodes([1, 2], { 0: 'a' })).toStrictEqual([
[DiffCode.Set, ['0'], 'a'],
[DiffCode.Delete, ['1'], undefined],
]);
});
});
describe('undefined data', () => {
it('does not diff undefined against undefined', () => {
expect(computeDiff(undefined, undefined)).toStrictEqual([]);
expect(computeDiff({ a: undefined }, { a: undefined })).toStrictEqual([]);
});
it('Sets a real value over a baseline undefined', () => {
expect(computeDiff({ a: undefined }, { a: 1 })).toStrictEqual([
[DiffCode.Set, ['a'], 1],
]);
});
it('Sets undefined over a baseline value', () => {
expect(computeDiff({ a: 1 }, { a: undefined })).toStrictEqual([
[DiffCode.Set, ['a'], undefined],
]);
});
it('never throws when either whole input is undefined', () => {
expect(() => computeDiff(undefined, { a: 1 })).not.toThrow();
expect(() => computeDiff({ a: 1 }, undefined)).not.toThrow();
expect(computeDiff(undefined, { a: 1 })).toStrictEqual([
[DiffCode.Set, [], { a: 1 }],
]);
});
});
describe('unsupported / non-JSON values', () => {
it('treats functions as leaves and never throws', () => {
expect(() => computeDiff({ fn: noop }, { fn: noop })).not.toThrow();
// Two functions both serialize to `undefined`, so they look equal.
expect(computeDiff({ fn: noop }, { fn: noop })).toStrictEqual([]);
});
it('Sets a function over a scalar (treated as a differing leaf)', () => {
const ops = computeDiff({ a: 1 }, { a: noop });
expect(ops).toHaveLength(1);
expect(ops[0][0]).toBe(DiffCode.Set);
expect(ops[0][1]).toStrictEqual(['a']);
});
it('does not throw on NaN / Infinity leaves', () => {
expect(() => computeDiff({ a: NaN }, { a: Infinity })).not.toThrow();
// Both stringify to "null", so the diff cannot tell them apart.
expect(computeDiff({ a: NaN }, { a: Infinity })).toStrictEqual([]);
});
});
describe('prototype-pollution hardening', () => {
afterEach(() => {
// Guard against the test itself leaking pollution into later suites.
delete (Object.prototype as Record<string, unknown>).polluted;
});
it('skips a JSON-injected own __proto__ key (emits no op for it)', () => {
const malicious = JSON.parse(
'{"safe":2,"__proto__":{"polluted":true}}',
) as Record<string, unknown>;
// Base must be a non-empty object so both sides reach diffObjects;
// an empty `{}` is a leaf and would collapse to a wholesale Set.
const ops = computeDiff({ safe: 1 }, malicious);
expect(paths(ops)).toStrictEqual(['safe']);
expect(paths(ops)).not.toContain('__proto__');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('skips own constructor and prototype keys', () => {
const ops = diffObjects({}, {
constructor: 'x',
prototype: 'y',
safe: 1,
} as Record<string, unknown>);
expect(paths(ops)).toStrictEqual(['safe']);
});
it('emits no Delete op when the baseline carries a forbidden key', () => {
const ops = diffObjects({ constructor: 'x' } as Record<string, unknown>, {});
expect(ops).toStrictEqual([]);
});
it('skips a nested __proto__ key reached via recursion', () => {
const malicious = JSON.parse(
'{"a":{"keep":1,"__proto__":{"polluted":true}}}',
) as Record<string, unknown>;
const ops = computeDiff({ a: { keep: 1 } }, malicious);
expect(ops).toStrictEqual([]);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('op-list invariants', () => {
it('produces a unique path per op (order-independent list)', () => {
const base = { a: 1, b: [1, 2, 3], c: { d: 4 } };
const query = { a: 9, b: [1], c: { d: 4, e: 5 }, f: 6 };
const list = paths(computeDiff(base, query));
expect(new Set(list).size).toBe(list.length);
});
});
});

View File

@@ -0,0 +1,89 @@
import { isContainer, isEmptyContainer, isLeaf } from '../predicates';
const noop = (): void => undefined;
describe('qsAlias/diff predicates', () => {
describe('isContainer', () => {
it('is true for plain objects and arrays', () => {
expect(isContainer({})).toBe(true);
expect(isContainer({ a: 1 })).toBe(true);
expect(isContainer([])).toBe(true);
expect(isContainer([1, 2])).toBe(true);
});
it('is false for null and undefined', () => {
expect(isContainer(null)).toBe(false);
expect(isContainer(undefined)).toBe(false);
});
it('is false for scalars', () => {
expect(isContainer('')).toBe(false);
expect(isContainer('str')).toBe(false);
expect(isContainer(0)).toBe(false);
expect(isContainer(42)).toBe(false);
expect(isContainer(NaN)).toBe(false);
expect(isContainer(true)).toBe(false);
expect(isContainer(false)).toBe(false);
});
it('is false for functions and symbols', () => {
expect(isContainer(noop)).toBe(false);
expect(isContainer(Symbol('x'))).toBe(false);
});
it('is true for exotic objects like Date (typeof object)', () => {
expect(isContainer(new Date(0))).toBe(true);
});
});
describe('isEmptyContainer', () => {
it('is true only for [] and {}', () => {
expect(isEmptyContainer([])).toBe(true);
expect(isEmptyContainer({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isEmptyContainer([1])).toBe(false);
expect(isEmptyContainer({ a: 1 })).toBe(false);
});
it('is false for scalars, null and undefined', () => {
expect(isEmptyContainer(null)).toBe(false);
expect(isEmptyContainer(undefined)).toBe(false);
expect(isEmptyContainer('')).toBe(false);
expect(isEmptyContainer(0)).toBe(false);
});
it('treats objects with only non-enumerable keys (Date) as empty', () => {
// Date has no own *enumerable* keys, so Object.keys() is empty.
expect(isEmptyContainer(new Date(0))).toBe(true);
});
});
describe('isLeaf', () => {
it('is true for every scalar', () => {
['', 'str', 0, 1, -1, 3.14, true, false].forEach((value) => {
expect(isLeaf(value)).toBe(true);
});
});
it('is true for null and undefined', () => {
expect(isLeaf(null)).toBe(true);
expect(isLeaf(undefined)).toBe(true);
});
it('is true for empty containers', () => {
expect(isLeaf([])).toBe(true);
expect(isLeaf({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isLeaf([1])).toBe(false);
expect(isLeaf({ a: 1 })).toBe(false);
});
it('counts a key whose value is undefined as non-empty (not a leaf)', () => {
expect(isLeaf({ a: undefined })).toBe(false);
});
});
});

View File

@@ -0,0 +1,178 @@
import { Json, PathSeg } from './predicates';
export const DiffCode = {
Set: 1,
Delete: 2,
} as const;
export type DiffCodeValue = (typeof DiffCode)[keyof typeof DiffCode];
/**
* A single diff operation: `[code, path, value]`. `value` is `undefined` for deletes.
*/
export type DiffOp = [code: DiffCodeValue, path: PathSeg[], value: Json];
/**
* Keys that must never reach a downstream `set`/rebuild step. Walking these
* would let a crafted query poison `Object.prototype`. They are skipped on both
* sides of the diff, so neither a SET nor a DELETE op is ever produced for them.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Array paths that use template-based diffing (added elements diff against [0]).
* These are query builder arrays where added items are structurally similar.
*/
const TEMPLATE_ARRAY_PATHS = [
['builder', 'queryData'],
['builder', 'queryFormulas'],
['builder', 'queryTraceOperator'],
['promql'],
['clickhouse_sql'],
];
function isTemplateArrayPath(path: PathSeg[]): boolean {
return TEMPLATE_ARRAY_PATHS.some(
(pattern) =>
pattern.length === path.length && pattern.every((seg, i) => seg === path[i]),
);
}
const hasOwn = (obj: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const leavesEqual = (a: Json, b: Json): boolean =>
JSON.stringify(a) === JSON.stringify(b);
/**
* Diff two arrays element-wise.
* Extra query items are SET; missing ones DELETE.
* Special case: if query is empty but baseline isn't, emit a single SET of `[]`
* rather than individual DELETEs, so the empty array survives the round-trip.
*
* For known query builder arrays (queryData, queryFormulas, etc.), added elements
* diff against baseArr[0] as template to minimize output size.
*/
export function diffArrays(
baseArr: Json[],
queryArr: Json[],
path: PathSeg[] = [],
): DiffOp[] {
// If query is empty but baseline has elements, emit SET of [] to preserve it.
if (queryArr.length === 0 && baseArr.length > 0) {
return [[DiffCode.Set, path, []]];
}
// Use template diffing for known query builder arrays
const useTemplate = isTemplateArrayPath(path) && baseArr.length > 0;
const template = useTemplate ? baseArr[0] : undefined;
const ops: DiffOp[] = [];
const maxLen = Math.max(baseArr.length, queryArr.length);
for (let i = 0; i < maxLen; i += 1) {
const segPath = [...path, i];
if (i >= queryArr.length) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (i >= baseArr.length) {
// Use template diffing if available, otherwise wholesale SET
if (template !== undefined) {
ops.push(...diffNodes(template, queryArr[i], segPath));
} else {
ops.push([DiffCode.Set, segPath, queryArr[i]]);
}
} else {
ops.push(...diffNodes(baseArr[i], queryArr[i], segPath));
}
}
return ops;
}
/**
* Diff two plain objects by own keys. Forbidden keys are skipped entirely.
* Special case: if query is empty but baseline isn't, emit a single SET of `{}`
* rather than individual DELETEs, so the empty object survives the round-trip.
*/
export function diffObjects(
baseObj: Record<string, Json>,
queryObj: Record<string, Json>,
path: PathSeg[] = [],
): DiffOp[] {
const baseKeys = Object.keys(baseObj).filter((k) => !FORBIDDEN_KEYS.has(k));
const queryKeys = Object.keys(queryObj).filter((k) => !FORBIDDEN_KEYS.has(k));
// If query is empty but baseline has keys, emit SET of {} to preserve it.
if (queryKeys.length === 0 && baseKeys.length > 0) {
return [[DiffCode.Set, path, {}]];
}
const ops: DiffOp[] = [];
const allKeys = new Set([...baseKeys, ...queryKeys]);
for (const key of allKeys) {
const segPath = [...path, key];
if (!hasOwn(queryObj, key)) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (!hasOwn(baseObj, key)) {
ops.push([DiffCode.Set, segPath, queryObj[key]]);
} else {
ops.push(...diffNodes(baseObj[key], queryObj[key], segPath));
}
}
return ops;
}
/**
* Diff any two nodes, dispatching on their shape.
*/
export function diffNodes(
baseline: Json,
query: Json,
path: PathSeg[] = [],
): DiffOp[] {
const baseIsArray = Array.isArray(baseline);
const queryIsArray = Array.isArray(query);
const baseIsObj =
typeof baseline === 'object' && baseline !== null && !baseIsArray;
const queryIsObj =
typeof query === 'object' && query !== null && !queryIsArray;
// Both arrays: walk element-wise even if one is empty. This ensures paths
// like `['builder', 'queryFormulas', 0, ...]` are emitted (not a wholesale
// SET on the array itself), which is required for prefix substitution.
if (baseIsArray && queryIsArray) {
return diffArrays(baseline, query, path);
}
// Both plain objects (including empty ones): walk key-wise.
if (baseIsObj && queryIsObj) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// Both scalars (non-containers): emit a SET only when they differ.
if (!baseIsArray && !baseIsObj && !queryIsArray && !queryIsObj) {
return leavesEqual(baseline, query) ? [] : [[DiffCode.Set, path, query]];
}
// Mixed container types (array-vs-object): walk key-wise, treating array
// indices as string keys. This is an edge case but preserves intent.
if ((baseIsArray || baseIsObj) && (queryIsArray || queryIsObj)) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// True shape mismatch: scalar vs container → replace wholesale.
return [[DiffCode.Set, path, query]];
}
/**
* Entry point: diff a baseline against a query, rooted at the empty path.
*/
export function computeDiff(baseline: Json, query: Json): DiffOp[] {
return diffNodes(baseline, query, []);
}

View File

@@ -0,0 +1,21 @@
/**
* Value-shape predicates shared by the diff algorithm and the codec's leaf
* walker. A "leaf" is anything the serializer emits as a single token: a
* scalar (string/number/boolean/null/undefined), or an *empty* container
* (`[]` / `{}`). Non-empty containers are walked recursively.
*/
export type Json = unknown;
export type PathSeg = string | number;
export const isContainer = (
value: Json,
): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
export const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
export const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);

View File

@@ -0,0 +1,33 @@
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import { decode, encode } from './codec';
const TAG_KEY = '_t';
const TAG_PREFIX = 'QA';
/**
* qsAlias (QA~): readable URL serialization with prefix substitution
* and field aliasing. Outputs multiple query params instead of single
* compositeQuery param.
*
* Format: _t=QAm&query0.ds=traces&query0.aa.key=http.status_code...
*
* Tags: QAm (metrics), QAl (logs), QAt (traces)
*/
export const qsAliasAdapter: CompositeQueryAdapter = {
name: 'qs-alias',
encode: (query) => {
const { params } = encode(query);
return params;
},
matches: (params) => {
const tag = params.get(TAG_KEY) ?? '';
return (
tag === `${TAG_PREFIX}m` ||
tag === `${TAG_PREFIX}l` ||
tag === `${TAG_PREFIX}t`
);
},
decode: (params) => decode(params),
};
export { encode as encodeQsAlias, decode as decodeQsAlias } from './codec';

View File

@@ -0,0 +1,51 @@
/**
* Leaf value codec: lossless, readable scalar encoding for the qsAlias wire.
*
* The wire is untyped text, so a string `"123"` and a number `123` would
* otherwise be indistinguishable after a round-trip. To disambiguate without
* hurting readability:
*
* - Strings are emitted verbatim (`traces`, `service.name`, …) — readable.
* - Every non-string scalar and empty container is type-tagged with a leading
* `_` followed by its JSON form (`_123`, `_true`, `_null`, `_[]`, `_{}`).
* - A string that itself begins with `_` is escaped by doubling the leading
* `_`, so it round-trips as a string instead of being read as a tag.
* - `undefined` has no URL representation and is normalized to `null`.
*
* `_` is used as the tag because it is left unescaped by both qs
* (`encodeValuesOnly`) and `URLSearchParams`, keeping tagged values readable.
* Wire-special characters (`&`, `=`, `%`, …) are NOT handled here — the caller
* percent-encodes values via qs `encodeValuesOnly`.
*/
import { Json } from './diff/predicates';
const TYPE_TAG = '_';
/** Encode a single leaf value into its wire token. */
export function encodeLeaf(value: Json): string {
if (value === undefined) {
return `${TYPE_TAG}null`;
}
if (typeof value === 'string') {
// Double the leading tag so a literal string survives as a string.
return value.startsWith(TYPE_TAG) ? `${TYPE_TAG}${value}` : value;
}
return `${TYPE_TAG}${JSON.stringify(value)}`;
}
/** Decode a wire token back into its leaf value. */
export function decodeLeaf(token: string): Json {
if (!token.startsWith(TYPE_TAG)) {
return token;
}
// `__…` is an escaped string — strip exactly one tag.
if (token[TYPE_TAG.length] === TYPE_TAG) {
return token.slice(TYPE_TAG.length);
}
try {
return JSON.parse(token.slice(TYPE_TAG.length));
} catch {
// Hand-crafted / corrupted token — fall back to raw text, never throw.
return token;
}
}

View File

@@ -0,0 +1,70 @@
/**
* Path and field alias maps for qsAlias encoder.
*
* PREFIX SUBSTITUTION:
* builder.queryData.0.field → query0.field
* builder.queryFormulas.0.field → formula0.field
* builder.queryTraceOperator.0.field → traceOp0.field
* promql.0.field → promql0.field
* clickhouse_sql.0.field → chsql0.field
*
* FIELD ALIASES (long → short):
* aggregateAttribute → aggAttr
* timeAggregation → timeAgg
* spaceAggregation → spaceAgg
*/
interface PrefixPattern {
match: string[];
prefix: string;
}
export const PREFIX_PATTERNS: PrefixPattern[] = [
{ match: ['builder', 'queryData'], prefix: 'query' },
{ match: ['builder', 'queryFormulas'], prefix: 'formula' },
{ match: ['builder', 'queryTraceOperator'], prefix: 'traceOp' },
{ match: ['promql'], prefix: 'promql' },
{ match: ['clickhouse_sql'], prefix: 'chsql' },
];
export const PREFIX_REVERSE: Record<string, string[]> = {
query: ['builder', 'queryData'],
formula: ['builder', 'queryFormulas'],
traceOp: ['builder', 'queryTraceOperator'],
promql: ['promql'],
chsql: ['clickhouse_sql'],
};
export const FIELD_ALIASES: Record<string, string> = {
aggregateAttribute: 'aggAttr',
aggregateOperator: 'aggOp',
timeAggregation: 'timeAgg',
spaceAggregation: 'spaceAgg',
stepInterval: 'stepIn',
dataSource: 'ds',
queryName: 'qn',
dataType: 'dt',
isColumn: 'ic',
isJSON: 'ij',
metricName: 'mn',
temporality: 'tp',
queryType: 'qt',
};
export const FIELD_REVERSE: Record<string, string> = Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([k, v]) => [v, k]),
);
/**
* Keys that belong to the qsAlias format. Anything else is a foreign param.
* Derived from PREFIX_PATTERNS + known top-level Query keys (and their aliases).
*/
const OWNED_PREFIXES = PREFIX_PATTERNS.map((p) => p.prefix).join('|');
const OWNED_TOP_LEVEL = ['id', 'queryType', 'qt', 'unit'];
const OWNED_KEY_PATTERN = new RegExp(
`^(?:_t|-?(?:${OWNED_PREFIXES})\\d+|${OWNED_TOP_LEVEL.join('|')})(?:\\.|$)`,
);
export function isOwnedKey(key: string): boolean {
return OWNED_KEY_PATTERN.test(key);
}

View File

@@ -0,0 +1,85 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export interface RoundTripScenario {
name: string;
query: Query;
}
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const makePromqlQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
return query;
};
const makeClickhouseQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
return query;
};
const makeModifiedBuilderQuery = (): Query => {
const query = clone(initialQueriesMap.logs);
const qd = query.builder.queryData[0];
qd.aggregateOperator = 'p95';
qd.disabled = true;
qd.stepInterval = 60;
qd.legend = 'error rate';
qd.filter = { expression: "severity_text = 'ERROR'" };
qd.filters = {
op: 'AND',
items: [
{
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
id: 'item-1',
op: '=',
value: 'ERROR',
},
],
};
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
return query;
};
const makeQueryWithCustomId = (): Query => ({
...initialQueriesMap.metrics,
id: 'test-query-uuid-123',
});
const makeQueryWithEnumLikeLegend = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].legend = 'sum';
query.id = 'my-query-id';
return query;
};
const makeQueryWithWireDelimiters = (): Query => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_a*b_*c';
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
return query;
};
export const roundTripScenarios: RoundTripScenario[] = [
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
{ name: 'logs baseline', query: initialQueriesMap.logs },
{ name: 'traces baseline', query: initialQueriesMap.traces },
{ name: 'promql query', query: makePromqlQuery() },
{ name: 'clickhouse query', query: makeClickhouseQuery() },
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
{ name: 'custom id', query: makeQueryWithCustomId() },
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
];

View File

@@ -0,0 +1,57 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for logs/traces queries — uses expression-style aggregations.
*/
export const LOGS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,65 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for metrics queries — uses metric-style aggregations object.
*/
export const METRICS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: 'avg',
},
],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,57 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for traces queries — same as logs but with dataSource: traces.
*/
export const TRACES_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'traces',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,38 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
/**
* Baseline tag indicators for URL encoding.
*/
export type BaselineTag = 'm' | 'l' | 't';
/**
* Pick optimal baseline based on query's primary dataSource.
*/
export function pickBaseline(query: Query): {
baseline: Query;
tag: BaselineTag;
} {
const ds = query.builder?.queryData?.[0]?.dataSource;
if (ds === 'logs') {
return { baseline: LOGS_BASELINE_V1, tag: 'l' };
}
if (ds === 'traces') {
return { baseline: TRACES_BASELINE_V1, tag: 't' };
}
return { baseline: METRICS_BASELINE_V1, tag: 'm' };
}
function getBaselineByTag(tag: BaselineTag): Query {
if (tag === 'l') {
return LOGS_BASELINE_V1;
}
if (tag === 't') {
return TRACES_BASELINE_V1;
}
return METRICS_BASELINE_V1;
}
export default getBaselineByTag;

View File

@@ -0,0 +1,80 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import {
COMPOSITE_QUERY_KEY,
CompositeQueryAdapter,
} from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from 'lib/compositeQuery/adapters/qsAlias';
// Order matters for decode: most-specific (tagged) adapters first
const ADAPTERS: CompositeQueryAdapter[] = [qsAliasAdapter, jsonAdapter];
// Pick the adapter that owns a given URL. json's `matches` is always true, so
// it serves as the final fallback when no tagged adapter claims the params.
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
}
/**
* Encode a query to the shortest available URLSearchParams.
*/
export function serialize(query: Query): URLSearchParams {
return ADAPTERS[0].encode(query);
}
/**
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
*/
export function deserialize(params: URLSearchParams): Query | null {
const hasParams = Array.from(params.keys()).length > 0;
if (!hasParams) {
return null;
}
try {
return adapterFor(params).decode(params);
} catch {
return null;
}
}
/**
* Apply all params from source into target URLSearchParams.
*/
export function applySerializedParams(
source: URLSearchParams,
target: URLSearchParams,
): void {
source.forEach((value, key) => target.set(key, value));
}
/**
* Remove every serialized-query param from target URLSearchParams. Use instead
* of `target.delete('compositeQuery')` so a stale query is fully purged even
* for adapters that explode a query into many content-dependent keys (e.g.
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
*
* Keys are discovered by round-trip: decode the current params with their
* owning adapter, re-encode, then delete exactly the keys encoding produces.
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
* single key so a stale `compositeQuery` is still cleared.
*/
export function clearSerializedParams(target: URLSearchParams): void {
const adapter = adapterFor(target);
try {
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
target.delete(key);
});
} catch {
target.delete(COMPOSITE_QUERY_KEY);
}
}
/**
* Serialize a query to a plain record of all URL params it produces. Use when
* building a query-param object manually (e.g. for `createQueryParams`), so the
* call site carries every param the adapter emits — not just `compositeQuery`.
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
*/
export function serializeToParams(query: Query): Record<string, string> {
return Object.fromEntries(serialize(query));
}

View File

@@ -0,0 +1,15 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
/**
* A serialization tier. `encode` returns URLSearchParams (default key =
* `compositeQuery`). `matches` checks if params belong to this adapter.
* `decode` receives URLSearchParams and returns Query.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): URLSearchParams;
matches(params: URLSearchParams): boolean;
decode(params: URLSearchParams): Query;
}

View File

@@ -15,6 +15,7 @@ import EditRulesContainer from 'container/EditRules';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import {
NEW_ALERT_SCHEMA_VERSION,
@@ -49,7 +50,7 @@ function EditRules(): JSX.Element {
const { notifications } = useNotifications();
const clickHandler = (): void => {
params.delete(QueryParams.compositeQuery);
clearSerializedParams(params);
params.delete(QueryParams.panelTypes);
params.delete(QueryParams.ruleId);
params.delete(QueryParams.relativeTime);

View File

@@ -31,6 +31,10 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import {
TraceDetailEventKeys,
TraceDetailEvents,
@@ -246,7 +250,7 @@ function SpanDetailsContent({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -38,6 +38,10 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
@@ -990,10 +994,7 @@ export function QueryBuilderProvider({
);
}
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
);
applySerializedParams(serialize(currentGeneratedQuery), urlQuery);
if (searchParams) {
Object.keys(searchParams).forEach((param) =>

View File

@@ -2,6 +2,7 @@ import { generatePath } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
type GenerateExportToDashboardLinkParams = {
@@ -21,6 +22,4 @@ export const generateExportToDashboardLink = ({
dashboardId,
})}/new?${QueryParams.graphType}=${panelType}&${
QueryParams.widgetId
}=${widgetId}&${QueryParams.compositeQuery}=${encodeURIComponent(
JSON.stringify(query),
)}`;
}=${widgetId}&${serialize(query).toString()}`;