Compare commits

...

8 Commits

8 changed files with 887 additions and 53 deletions

View File

@@ -0,0 +1,134 @@
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
const UNCONSTRAINED_ROUTE = '/unconstrained';
const renderOn = (
compositeQuery: string,
route: string = UNCONSTRAINED_ROUTE,
): Query | null => {
const search = new URLSearchParams();
search.set('compositeQuery', compositeQuery);
const { result } = renderHook(() => useGetCompositeQueryParam(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={`${route}?${search}`}>
{children}
</AllTheProviders>
),
});
return result.current;
};
const logsWith = (item: object): string =>
JSON.stringify({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
},
});
const withHavingConverted = (queryData: object[]): object[] =>
queryData.map((item) => ({ ...item, having: { expression: '' } }));
describe('useGetCompositeQueryParam', () => {
it('treats a non-object or broken compositeQuery as no query', () => {
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
expect(renderOn(raw)).toBeNull(),
);
});
it('repairs the builder shape and converts the legacy having', () => {
const defaultQueryData = withHavingConverted(
initialQueryState.builder.queryData,
);
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
(queryData) =>
expect(
renderOn(
JSON.stringify({
...initialQueriesMap.logs,
builder: { ...initialQueriesMap.logs.builder, queryData },
}),
)?.builder.queryData,
).toStrictEqual(defaultQueryData),
);
expect(
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
).toStrictEqual({ expression: '' });
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
...initialQueriesMap.logs,
builder: {
...initialQueriesMap.logs.builder,
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
},
});
});
it('gives an id-less query with a filter the same id on every parse', () => {
const { id: _, ...withoutId } = JSON.parse(
logsWith({ filter: { expression: "service.name='x'" } }),
);
const first = renderOn(JSON.stringify(withoutId));
const again = renderOn(JSON.stringify(withoutId));
const other = renderOn(
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
);
expect(first?.id).toHaveLength(36);
expect(again?.id).toBe(first?.id);
expect(other?.id).not.toBe(first?.id);
});
it('applies the page constraints on every constrained route', () => {
const clickhouseWithOperator = JSON.stringify({
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
builder: {
...initialQueriesMap.logs.builder,
queryData: [
{
...initialQueriesMap.metrics.builder.queryData[0],
source: TelemetrytypesSourceDTO.meter,
},
],
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
},
});
[
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
].forEach(([route, dataSource, source]) => {
const query = renderOn(clickhouseWithOperator, route);
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
expect(query?.builder.queryData[0].source ?? '').toBe(source);
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
});
});
it('keeps the query type on a route without constraints', () => {
const query = renderOn(
JSON.stringify({
...initialQueriesMap.metrics,
queryType: EQueryType.CLICKHOUSE,
}),
);
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
});
});

View File

@@ -0,0 +1,54 @@
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
export type ExplorerQueryRules = {
dataSource: DataSource;
/** `meter` on the meter explorer, which shares the metrics signal; '' elsewhere. */
source: BuilderQuerySource;
builderQueryType: BuilderQueryType;
allowsTraceOperator: boolean;
};
const EXPLORER_QUERY_RULES: Partial<Record<string, ExplorerQueryRules>> = {
[ROUTES.LOGS_EXPLORER]: {
dataSource: DataSource.LOGS,
source: '',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
[ROUTES.TRACES_EXPLORER]: {
dataSource: DataSource.TRACES,
source: '',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: true,
},
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
dataSource: DataSource.TRACES,
source: '',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
allowsTraceOperator: false,
},
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
dataSource: DataSource.METRICS,
source: '',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
[ROUTES.METER_EXPLORER]: {
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
};
export function getExplorerQueryRules(
pathname: string,
): ExplorerQueryRules | undefined {
return EXPLORER_QUERY_RULES[pathname];
}

View File

@@ -0,0 +1,297 @@
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
IBuilderQuery,
Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { normalizeCompositeQuery } from './normalizeCompositeQuery';
const NOT_AN_EXPLORER = ROUTES.HOME;
const logsQuery = initialQueryBuilderFormValuesMap.logs;
const tracesQuery = initialQueryBuilderFormValuesMap.traces;
const metricsQuery = initialQueryBuilderFormValuesMap.metrics;
function withBuilderQueries(
query: Query,
queryData: unknown[],
builder: object = {},
): Query {
return {
...query,
builder: {
...query.builder,
queryData: queryData as IBuilderQuery[],
...builder,
},
};
}
function firstBuilderQuery(
query: Query,
queryData: unknown[],
pathname: string,
): IBuilderQuery {
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
.query.builder.queryData[0];
}
function without(builderQuery: object, key: keyof IBuilderQuery): object {
const { [key]: _, ...rest } = builderQuery as IBuilderQuery;
return rest;
}
const traceOperator = { queryTraceOperator: [tracesQuery] };
describe('normalizeCompositeQuery', () => {
it('leaves complete queries untouched', () => {
const clickhouse = {
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
extra: 'kept',
};
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
]);
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
expect(
normalizeCompositeQuery(initialQueriesMap.logs, pathname),
).toStrictEqual({ query: initialQueriesMap.logs, changed: false }),
);
expect(normalizeCompositeQuery(clickhouse, NOT_AN_EXPLORER)).toStrictEqual({
query: clickhouse,
changed: false,
});
expect(
normalizeCompositeQuery(
withBuilderQueries(initialQueriesMap.logs, [without(logsQuery, 'source')]),
ROUTES.LOGS_EXPLORER,
).changed,
).toBe(false);
expect(
normalizeCompositeQuery(aiQuery, ROUTES.AI_OBSERVABILITY_EXPLORER).changed,
).toBe(false);
});
it('adds missing blocks and a stable id without changing its inputs', () => {
const { builder: _, ...builderless } = {
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
};
const urlQueryBefore = JSON.stringify(builderless);
const defaultsBefore = JSON.stringify(initialQueryState.builder);
const repaired = normalizeCompositeQuery(builderless, NOT_AN_EXPLORER);
repaired.query.builder.queryData[0].queryName = 'changed';
const {
promql: _p,
clickhouse_sql: _c,
...withoutText
} = initialQueriesMap.logs;
const withoutLists = normalizeCompositeQuery(
{
...initialQueriesMap.logs,
builder: { queryData: initialQueriesMap.logs.builder.queryData },
},
NOT_AN_EXPLORER,
);
const { id: _id, ...withoutId } = initialQueriesMap.logs;
const { id } = normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query;
expect(repaired.query.queryType).toBe(EQueryType.CLICKHOUSE);
expect(repaired.changed).toBe(true);
expect(JSON.stringify(builderless)).toBe(urlQueryBefore);
expect(JSON.stringify(initialQueryState.builder)).toBe(defaultsBefore);
expect(
normalizeCompositeQuery(withoutText, NOT_AN_EXPLORER).query,
).toMatchObject({
promql: initialQueryState.promql,
clickhouse_sql: initialQueryState.clickhouse_sql,
});
expect(withoutLists.query.builder.queryFormulas).toStrictEqual([]);
expect(withoutLists.query.builder.queryTraceOperator).toStrictEqual([]);
expect(withoutLists.changed).toBe(true);
[[], [5, logsQuery]].forEach((queryData) =>
expect(
normalizeCompositeQuery(
withBuilderQueries(initialQueriesMap.logs, queryData),
NOT_AN_EXPLORER,
).query.builder.queryData,
).toStrictEqual(initialQueryState.builder.queryData),
);
expect(id).toHaveLength(36);
expect(normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query.id).toBe(id);
expect(
normalizeCompositeQuery(
{ ...withoutId, queryType: EQueryType.PROM },
NOT_AN_EXPLORER,
).query.id,
).not.toBe(id);
});
it('switches explorer queries to the query builder and clears their text', () => {
const withText = normalizeCompositeQuery(
{
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
},
ROUTES.LOGS_EXPLORER,
);
const { builder: _, ...builderless } = {
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
};
expect(withText.query).toMatchObject({
queryType: EQueryType.QUERY_BUILDER,
clickhouse_sql: initialQueryState.clickhouse_sql,
promql: initialQueryState.promql,
});
expect(withText.changed).toBe(true);
expect(
normalizeCompositeQuery(builderless, ROUTES.LOGS_EXPLORER).query,
).toMatchObject({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ ...logsQuery, source: '' }] },
});
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
expect(
normalizeCompositeQuery(
{ ...initialQueriesMap.traces, queryType },
ROUTES.TRACES_EXPLORER,
).query.queryType,
).toBe(EQueryType.QUERY_BUILDER),
);
});
it('resets builder queries that do not match the explorer, keeping their names', () => {
const logs = initialQueriesMap.logs;
const traces = initialQueriesMap.traces;
const ai = ROUTES.AI_OBSERVABILITY_EXPLORER;
const severityFilter = { filter: { expression: "severity='error'" } };
const llmFilter = { filter: { expression: "name='llm'" } };
expect(
firstBuilderQuery(
logs,
[{ ...tracesQuery, queryName: 'B', expression: 'B', ...llmFilter }],
ROUTES.LOGS_EXPLORER,
),
).toStrictEqual({
...logsQuery,
source: '',
queryName: 'B',
expression: 'B',
});
expect(
firstBuilderQuery(
logs,
[{ ...without(logsQuery, 'dataSource'), ...severityFilter }],
ROUTES.LOGS_EXPLORER,
),
).toMatchObject({ dataSource: DataSource.LOGS, ...severityFilter });
expect(
firstBuilderQuery(
logs,
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
ROUTES.LOGS_EXPLORER,
).builderQueryType,
).toBeUndefined();
expect(
firstBuilderQuery(
traces,
[
{
...tracesQuery,
queryName: 'D',
builderQueryType: QueryMode.AI_QUERY_BUILDER,
...llmFilter,
},
],
ROUTES.TRACES_EXPLORER,
),
).toStrictEqual({ ...tracesQuery, source: '', queryName: 'D' });
expect(
firstBuilderQuery(
initialQueriesMap.metrics,
[{ ...without(metricsQuery, 'source'), filter: { expression: "x='y'" } }],
ROUTES.METER_EXPLORER,
),
).toMatchObject({
source: TelemetrytypesSourceDTO.meter,
filter: { expression: "x='y'" },
});
expect(
normalizeCompositeQuery(initialQueriesMap.metrics, ROUTES.METER_EXPLORER)
.query.builder.queryData[0].source,
).toBe(TelemetrytypesSourceDTO.meter);
expect(
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
...llmFilter,
});
expect(
firstBuilderQuery(
traces,
[
{
...tracesQuery,
queryName: 'B',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
...llmFilter,
},
],
ai,
),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
queryName: 'B',
filter: { expression: '' },
});
expect(
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
dataSource: DataSource.TRACES,
queryName: 'C',
});
});
it('drops a trace operator everywhere but the traces explorer', () => {
const withOperator = (
query: Query,
pathname: string,
): ReturnType<typeof normalizeCompositeQuery> =>
normalizeCompositeQuery(
withBuilderQueries(query, query.builder.queryData, traceOperator),
pathname,
);
const onLogs = withOperator(initialQueriesMap.logs, ROUTES.LOGS_EXPLORER);
const onTraces = withOperator(
initialQueriesMap.traces,
ROUTES.TRACES_EXPLORER,
);
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
expect(onLogs.changed).toBe(true);
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
expect(onTraces.changed).toBe(false);
});
});

View File

@@ -0,0 +1,196 @@
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, isPlainObject } from 'lodash-es';
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { v5 as uuidv5 } from 'uuid';
import {
ExplorerQueryRules,
getExplorerQueryRules,
} from './explorerQueryRules';
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
export type NormalizedQuery = {
query: Query;
/** The url query had to be repaired, so the url should be rewritten. */
changed: boolean;
};
/**
* Repairs a `compositeQuery` read from the url so the query builder can load it.
* Links built by agents can leave out whole blocks, or carry a query the
* current explorer cannot run.
*/
export function normalizeCompositeQuery(
urlQuery: object,
pathname: string,
): NormalizedQuery {
const query = cloneDeep(urlQuery) as Query;
addMissingBlocks(query, urlQuery);
const rules = getExplorerQueryRules(pathname);
if (rules) {
fitQueryToExplorer(query, rules);
}
return { query, changed: !isEqual(query, urlQuery) };
}
function addMissingBlocks(query: Query, urlQuery: object): void {
if (!query.id) {
query.id = getStableId(urlQuery);
}
if (!Object.values(EQueryType).includes(query.queryType)) {
query.queryType = EQueryType.QUERY_BUILDER;
}
if (!isListOfObjects(query.promql)) {
query.promql = cloneDeep(initialQueryState.promql);
}
if (!isListOfObjects(query.clickhouse_sql)) {
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
}
if (!isPlainObject(query.builder)) {
query.builder = cloneDeep(initialQueryState.builder);
}
const { queryData } = query.builder;
if (!isListOfObjects(queryData) || queryData.length === 0) {
query.builder.queryData = cloneDeep(initialQueryState.builder.queryData);
}
if (!query.builder.queryFormulas) {
query.builder.queryFormulas = [];
}
if (!query.builder.queryTraceOperator) {
query.builder.queryTraceOperator = [];
}
}
/**
* The provider matches the staged query to the url query by id, so a link
* without an id must get the same one on every read.
*/
function getStableId(urlQuery: object): string {
return uuidv5(JSON.stringify(urlQuery), COMPOSITE_QUERY_ID_NAMESPACE);
}
function isListOfObjects(value: unknown): boolean {
return Array.isArray(value) && value.every((item) => isPlainObject(item));
}
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
// Explorers only have the query builder: no PromQL or ClickHouse editor.
query.queryType = EQueryType.QUERY_BUILDER;
if (query.promql.some((promql) => promql.query !== '')) {
query.promql = cloneDeep(initialQueryState.promql);
}
if (query.clickhouse_sql.some((clickhouse) => clickhouse.query !== '')) {
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
}
if (!rules.allowsTraceOperator) {
query.builder.queryTraceOperator = [];
}
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
fitBuilderQueryToExplorer(builderQuery, rules),
);
}
function fitBuilderQueryToExplorer(
builderQuery: IBuilderQuery,
rules: ExplorerQueryRules,
): IBuilderQuery {
fillMissingSignal(builderQuery, rules);
if (belongsToExplorer(builderQuery, rules)) {
return builderQuery;
}
const replacement = getDefaultBuilderQuery(rules);
// Keep the name, so formulas that reference this query still resolve.
replacement.queryName = builderQuery.queryName ?? replacement.queryName;
replacement.expression = builderQuery.expression ?? replacement.expression;
return replacement;
}
/** A link may leave out the fields that pick the signal: read them as the explorer's. */
function fillMissingSignal(
builderQuery: IBuilderQuery,
rules: ExplorerQueryRules,
): void {
if (builderQuery.dataSource === undefined) {
builderQuery.dataSource = rules.dataSource;
}
// A missing `source` already means '', so only meter needs it written.
if (
builderQuery.source === undefined &&
rules.source === TelemetrytypesSourceDTO.meter
) {
builderQuery.source = rules.source;
}
// A missing `builderQueryType` already means builder_query, so only AI needs it written.
if (
builderQuery.builderQueryType === undefined &&
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
) {
builderQuery.builderQueryType = rules.builderQueryType;
}
}
function belongsToExplorer(
builderQuery: IBuilderQuery,
rules: ExplorerQueryRules,
): boolean {
if (builderQuery.dataSource !== rules.dataSource) {
return false;
}
if ((builderQuery.source ?? '') !== rules.source) {
return false;
}
const builderQueryType =
builderQuery.builderQueryType ??
Querybuildertypesv5QueryTypeDTO.builder_query;
if (builderQueryType !== rules.builderQueryType) {
return false;
}
return true;
}
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
const builderQuery = cloneDeep(
initialQueryBuilderFormValuesMap[rules.dataSource],
);
builderQuery.source = rules.source;
if (
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
) {
builderQuery.builderQueryType = rules.builderQueryType;
}
return builderQuery;
}

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
@@ -6,15 +7,23 @@ import {
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { isPlainObject } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
import {
NormalizedQuery,
normalizeCompositeQuery,
} from './normalizeCompositeQuery';
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
let changed = false;
try {
if (!compositeQuery) {
@@ -23,18 +32,18 @@ export const useGetCompositeQueryParam = (): Query | 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(
const rawQuery: unknown = JSON.parse(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Add default values for optional fields if empty
if (parsedCompositeQuery?.builder) {
parsedCompositeQuery.builder.queryFormulas =
parsedCompositeQuery.builder.queryFormulas ?? [];
parsedCompositeQuery.builder.queryTraceOperator =
parsedCompositeQuery.builder.queryTraceOperator ?? [];
if (!isPlainObject(rawQuery)) {
return null;
}
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
rawQuery as object,
pathname,
));
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
@@ -51,7 +60,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
const convertedHaving = convertHavingToExpression(
query.having.filter(isPlainObject),
);
convertedQuery.having = convertedHaving;
}
@@ -75,6 +86,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
}, [urlQuery, pathname]);
};
export const useGetCompositeQueryParam = (): Query | null =>
useNormalizedCompositeQueryParam()?.query ?? null;

View File

@@ -1,28 +0,0 @@
export function replaceIncorrectObjectFields<
// eslint-disable-next-line @typescript-eslint/ban-types
TargetValue extends object,
// eslint-disable-next-line @typescript-eslint/ban-types
ResultValue extends object,
>(
targetObject: TargetValue,
defaultObject: ResultValue,
): { isValid: boolean; validData: ResultValue } {
const targetObjectKeys = Object.keys(targetObject);
const defaultObjectKeys = Object.keys(defaultObject);
let isValid = true;
const result: ResultValue = { ...defaultObject };
defaultObjectKeys.forEach((key) => {
if (targetObjectKeys.includes(key)) {
result[key as keyof ResultValue] = targetObject[
key as keyof TargetValue
] as unknown as ResultValue[keyof ResultValue];
} else {
isValid = false;
}
});
return { isValid, validData: result };
}

View File

@@ -34,14 +34,13 @@ import {
PartialPanelTypes,
} from 'lib/query/panelTypeDataSourceFormValuesMap';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
import { cloneDeep, get, isEqual, set } from 'lodash-es';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
// ** Types
@@ -114,7 +113,8 @@ export function QueryBuilderProvider({
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
useState<boolean>(false);
const compositeQueryParam = useGetCompositeQueryParam();
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
const { queryType: queryTypeParam, ...queryState } =
compositeQueryParam || initialQueriesMap.metrics;
@@ -1115,20 +1115,22 @@ export function QueryBuilderProvider({
return;
}
const { isValid, validData } = replaceIncorrectObjectFields(
compositeQueryParam,
initialQueriesMap.metrics,
);
if (!isValid) {
redirectWithQueryBuilderData(validData);
} else {
initQueryBuilderData(compositeQueryParam);
if (normalizedCompositeQuery?.changed) {
const urlQuery = getUnstableCurrentSearchParams();
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQueryParam)),
);
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
return;
}
initQueryBuilderData(compositeQueryParam);
}
}, [
initQueryBuilderData,
redirectWithQueryBuilderData,
safeNavigate,
normalizedCompositeQuery,
compositeQueryParam,
stagedQuery,
location.pathname,

View File

@@ -0,0 +1,165 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
act,
AllTheProviders,
renderHook,
RenderHookResult,
waitFor,
} from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
jest.mock('hooks/useSafeNavigate', () =>
jest
.requireActual('tests/browser-history-safe-navigate')
.createBrowserHistorySafeNavigateMock(),
);
const builderlessUrlQuery = {
queryType: 'clickhouse_sql',
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
unit: '',
};
const clickhouseUrlQuery = {
...builderlessUrlQuery,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
};
const tracesWithOperator = {
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
},
};
const urlQuery = (): Query =>
JSON.parse(
decodeURIComponent(
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
),
);
const activeLogIdParam = (): string | null =>
new URLSearchParams(window.location.search).get('activeLogId');
const renderOn = (
route: string,
compositeQuery: object,
params: Record<string, string> = {},
): RenderHookResult<QueryBuilderContextType, unknown> => {
const search = new URLSearchParams(params);
search.set('compositeQuery', JSON.stringify(compositeQuery));
const url = `${route}?${search}`;
window.history.replaceState(null, '', url);
return renderHook(() => useQueryBuilder(), {
wrapper: ({ children }): JSX.Element => (
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
),
});
};
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
expect(result.current.supersetQuery.builder).toBeDefined();
act(() => {
result.current.handleSetQueryData(0, {
...initialQueryBuilderFormValuesMap.logs,
limit: 10,
});
});
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
});
});
describe('QueryBuilderProvider with page constraints', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
const replaceState = jest.spyOn(window.history, 'replaceState');
const pushState = jest.spyOn(window.history, 'pushState');
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
activeLogId: '"log-1"',
});
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
DataSource.LOGS,
);
await waitFor(() =>
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
);
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
expect(urlQuery().clickhouse_sql[0].query).toBe('');
expect(activeLogIdParam()).toBe('"log-1"');
expect(replaceState).toHaveBeenCalled();
expect(pushState).not.toHaveBeenCalled();
});
it('keeps a trace operator on the traces explorer', async () => {
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
await waitFor(() =>
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
1,
),
);
});
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
const query = {
...initialQueriesMap.logs,
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
};
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
activeLogId: '"log-1"',
});
await waitFor(() =>
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
);
expect(
new URLSearchParams(window.location.search).get('compositeQuery'),
).toBe(JSON.stringify(query));
expect(activeLogIdParam()).toBe('"log-1"');
});
it('leaves a clickhouse query alone on a route without constraints', async () => {
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
await waitFor(() =>
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
);
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
});
});