Compare commits

...

6 Commits

8 changed files with 813 additions and 52 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,297 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
IBuilderQuery,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
buildPageQuerySchema,
normalizeCompositeQuery,
} from './compositeQuerySchema';
const logsSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.LOGS,
});
const tracesSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
traceOperator: true,
});
const meterSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
});
const aiSchema = buildPageQuerySchema({
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
});
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 logsItem = (overrides: object = {}): object => ({
...initialQueryBuilderFormValuesMap.logs,
...overrides,
});
const queryOf = (
base: typeof initialQueriesMap.logs,
queryData: unknown[],
builder: object = {},
): object => ({
...base,
builder: { ...base.builder, queryData, ...builder },
});
const logsQuery = (queryData: unknown[], builder?: object): object =>
queryOf(initialQueriesMap.logs, queryData, builder);
const without = (item: object, key: keyof IBuilderQuery): object => {
const { [key]: _, ...rest } = item as IBuilderQuery;
return rest;
};
describe('normalizeCompositeQuery', () => {
it('passes valid queries through unchanged', () => {
const withoutSource = logsQuery([
without(initialQueriesMap.logs.builder.queryData[0], 'source'),
]);
const clickhouse = {
...initialQueriesMap.logs,
queryType: EQueryType.CLICKHOUSE,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
};
const unmodeled = normalizeCompositeQuery({
...logsQuery([logsItem({ builderQueryType: QueryMode.AI_QUERY_BUILDER })]),
extra: 'kept',
}).query;
expect(normalizeCompositeQuery(initialQueriesMap.logs)).toStrictEqual({
query: initialQueriesMap.logs,
changed: false,
});
expect(
normalizeCompositeQuery(initialQueriesMap.logs, logsSchema),
).toStrictEqual({ query: initialQueriesMap.logs, changed: false });
expect(normalizeCompositeQuery(withoutSource, logsSchema).changed).toBe(
false,
);
expect(
normalizeCompositeQuery(
clickhouse,
buildPageQuerySchema({ dataSource: DataSource.LOGS }),
),
).toStrictEqual({ query: clickhouse, changed: false });
expect(unmodeled.builder.queryData[0].builderQueryType).toBe(
QueryMode.AI_QUERY_BUILDER,
);
expect((unmodeled as { extra?: string }).extra).toBe('kept');
});
it('fills missing blocks with defaults and a stable id', () => {
const sharedBefore = JSON.stringify(initialQueryState.builder);
const builderless = normalizeCompositeQuery(builderlessUrlQuery);
builderless.query.builder.queryFormulas.push(
{} as (typeof builderless.query.builder.queryFormulas)[number],
);
const noArrays = normalizeCompositeQuery({
...initialQueriesMap.logs,
builder: { queryData: initialQueriesMap.logs.builder.queryData },
});
const omittedBlocks = normalizeCompositeQuery(
{
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [logsItem()], queryFormulas: [] },
},
logsSchema,
);
const { id: _, ...withoutId } = initialQueriesMap.logs;
const id = normalizeCompositeQuery(withoutId).query.id;
expect(builderless.query.queryType).toBe(EQueryType.CLICKHOUSE);
expect(builderless.query.id).toBe(builderlessUrlQuery.id);
expect(builderless.changed).toBe(true);
expect(JSON.stringify(initialQueryState.builder)).toBe(sharedBefore);
expect(noArrays.query.builder.queryFormulas).toStrictEqual([]);
expect(noArrays.query.builder.queryTraceOperator).toStrictEqual([]);
expect(noArrays.changed).toBe(true);
[[], [5, logsItem({ queryName: 'B' })]].forEach((queryData) =>
expect(
normalizeCompositeQuery(logsQuery(queryData)).query.builder.queryData,
).toStrictEqual(initialQueryState.builder.queryData),
);
expect(omittedBlocks.query.promql).toStrictEqual(initialQueryState.promql);
expect(omittedBlocks.query.clickhouse_sql).toStrictEqual(
initialQueryState.clickhouse_sql,
);
expect(omittedBlocks.query.builder.queryData[0].dataSource).toBe(
DataSource.LOGS,
);
expect(id).toHaveLength(36);
expect(normalizeCompositeQuery(withoutId).query.id).toBe(id);
expect(
normalizeCompositeQuery({ ...withoutId, queryType: EQueryType.PROM }).query
.id,
).not.toBe(id);
});
it('resets query types and text the page cannot run', () => {
const builderless = normalizeCompositeQuery(builderlessUrlQuery, logsSchema);
const withText = normalizeCompositeQuery(
{
...initialQueriesMap.logs,
clickhouse_sql: [
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
],
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
},
logsSchema,
);
expect(builderless.query.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(builderless.query.builder.queryData).toStrictEqual([
{ ...initialQueryBuilderFormValuesMap.logs, source: '' },
]);
expect(withText.query.clickhouse_sql).toStrictEqual(
initialQueryState.clickhouse_sql,
);
expect(withText.query.promql).toStrictEqual(initialQueryState.promql);
expect(withText.changed).toBe(true);
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
expect(
normalizeCompositeQuery(
{ ...initialQueriesMap.traces, queryType },
tracesSchema,
).query.queryType,
).toBe(EQueryType.QUERY_BUILDER),
);
});
it('resets items that do not match the page and keeps their names', () => {
const mixed = normalizeCompositeQuery(
logsQuery([
logsItem({
dataSource: DataSource.TRACES,
queryName: 'B',
expression: 'B',
filter: { expression: "name='GET /api'" },
}),
without(
logsItem({ filter: { expression: "severity='error'" } }),
'dataSource',
),
]),
logsSchema,
).query.builder.queryData;
const meterFilled = normalizeCompositeQuery(
queryOf(initialQueriesMap.metrics, [
{
...without(initialQueriesMap.metrics.builder.queryData[0], 'source'),
filter: { expression: "x='y'" },
},
]),
meterSchema,
).query.builder.queryData[0];
const metricsOnMeter = normalizeCompositeQuery(
initialQueriesMap.metrics,
meterSchema,
);
const tracesItem = {
...initialQueryBuilderFormValuesMap.traces,
filter: { expression: "name='llm'" },
};
const [aiMissing, aiWrong, aiLogs] = [
[tracesItem],
[
{
...tracesItem,
queryName: 'B',
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
},
],
[logsItem({ queryName: 'C' })],
].map(
(queryData) =>
normalizeCompositeQuery(
queryOf(initialQueriesMap.traces, queryData),
aiSchema,
).query.builder.queryData[0],
);
expect(mixed[0]).toStrictEqual({
...initialQueryBuilderFormValuesMap.logs,
source: '',
queryName: 'B',
expression: 'B',
});
expect(mixed[1]).toMatchObject({
dataSource: DataSource.LOGS,
filter: { expression: "severity='error'" },
});
expect(meterFilled).toMatchObject({
source: TelemetrytypesSourceDTO.meter,
filter: { expression: "x='y'" },
});
expect(metricsOnMeter.query.builder.queryData[0].source).toBe(
TelemetrytypesSourceDTO.meter,
);
expect(metricsOnMeter.changed).toBe(true);
expect(aiMissing).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
filter: { expression: "name='llm'" },
});
expect(aiWrong).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
queryName: 'B',
filter: { expression: '' },
});
expect(aiLogs).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
dataSource: DataSource.TRACES,
queryName: 'C',
});
});
it('clears a trace operator unless the page allows it', () => {
const operator = {
queryTraceOperator: [initialQueryBuilderFormValuesMap.traces],
};
const onLogs = normalizeCompositeQuery(
logsQuery([logsItem()], operator),
logsSchema,
);
const onTraces = normalizeCompositeQuery(
queryOf(
initialQueriesMap.traces,
initialQueriesMap.traces.builder.queryData,
operator,
),
tracesSchema,
);
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,134 @@
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, omitBy } from 'lodash-es';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
import { v5 as uuidv5 } from 'uuid';
import { z } from 'zod';
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
export type QueryConstraints = {
dataSource: DataSource;
source?: BuilderQuerySource;
builderQueryType?: BuilderQueryType;
traceOperator?: boolean;
queryTypes?: EQueryType[];
};
export type NormalizedQuery = {
query: Query;
changed: boolean;
};
const fallback = (value: unknown) => (): never => cloneDeep(value) as never;
const builderItemSchema = z
.looseObject({
dataSource: z.enum(DataSource).optional().catch(undefined),
source: z.string().optional().catch(undefined),
})
.transform((item) => omitBy(item, (value) => value === undefined));
const textItemSchema = z.looseObject({ query: z.string().catch('') });
const builderSchema = z.looseObject({
queryData: z
.array(builderItemSchema)
.min(1)
.catch(fallback(initialQueryState.builder.queryData)),
queryFormulas: z.unknown().transform((value) => value ?? []),
queryTraceOperator: z.unknown().transform((value) => value ?? []),
});
const compositeQuerySchema = z.looseObject({
queryType: z.enum(EQueryType).catch(fallback(EQueryType.QUERY_BUILDER)),
builder: builderSchema.catch(fallback(initialQueryState.builder)),
promql: z.array(textItemSchema).catch(fallback(initialQueryState.promql)),
clickhouse_sql: z
.array(textItemSchema)
.catch(fallback(initialQueryState.clickhouse_sql)),
});
const mustBeEmpty = (value: unknown): z.ZodType =>
z.array(z.looseObject({ query: z.literal('') })).catch(fallback(value));
export function buildPageQuerySchema(
constraints: QueryConstraints,
): z.ZodType<unknown> {
const { queryTypes } = constraints;
const itemDefault: IBuilderQuery = {
...initialQueryBuilderFormValuesMap[constraints.dataSource],
source: constraints.source ?? '',
...(constraints.builderQueryType && {
builderQueryType: constraints.builderQueryType,
}),
};
const allows = (type: EQueryType): boolean =>
!queryTypes || queryTypes.includes(type);
const pageItemSchema = builderItemSchema
.transform((item) => ({
...item,
dataSource: item.dataSource ?? constraints.dataSource,
...(constraints.source && { source: item.source ?? constraints.source }),
...(constraints.builderQueryType && {
builderQueryType: item.builderQueryType ?? constraints.builderQueryType,
}),
}))
.refine(
(item) =>
item.dataSource === constraints.dataSource &&
(item.source ?? '') === (constraints.source ?? '') &&
(!constraints.builderQueryType ||
item.builderQueryType === constraints.builderQueryType),
)
.catch(({ input }): never => {
const { queryName, expression } = input as Partial<IBuilderQuery>;
return {
...cloneDeep(itemDefault),
queryName: queryName ?? itemDefault.queryName,
expression: expression ?? itemDefault.expression,
} as never;
});
return compositeQuerySchema.extend({
queryType: queryTypes
? z
.enum(EQueryType)
.refine((type) => queryTypes.includes(type))
.catch(fallback(queryTypes[0]))
: compositeQuerySchema.shape.queryType,
promql: allows(EQueryType.PROM)
? compositeQuerySchema.shape.promql
: mustBeEmpty(initialQueryState.promql),
clickhouse_sql: allows(EQueryType.CLICKHOUSE)
? compositeQuerySchema.shape.clickhouse_sql
: mustBeEmpty(initialQueryState.clickhouse_sql),
builder: builderSchema
.extend({
queryData: z
.array(pageItemSchema)
.min(1)
.catch(fallback([itemDefault])),
queryTraceOperator: constraints.traceOperator
? builderSchema.shape.queryTraceOperator
: z.array(z.never()).catch(fallback([])),
})
.catch(fallback({ ...initialQueryState.builder, queryData: [itemDefault] })),
});
}
export function normalizeCompositeQuery(
raw: unknown,
pageSchema?: z.ZodType<unknown>,
): NormalizedQuery {
const base = compositeQuerySchema.parse(raw) as unknown as Query;
base.id ??= uuidv5(JSON.stringify(raw), COMPOSITE_QUERY_ID_NAMESPACE);
const query = (pageSchema ? pageSchema.parse(base) : base) as Query;
return { query, changed: !isEqual(query, raw) };
}

View File

@@ -0,0 +1,45 @@
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { z } from 'zod';
import { buildPageQuerySchema, QueryConstraints } from './compositeQuerySchema';
const PAGE_QUERY_CONSTRAINTS: Partial<Record<string, QueryConstraints>> = {
[ROUTES.LOGS_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.LOGS,
},
[ROUTES.TRACES_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
traceOperator: true,
},
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.TRACES,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
},
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
},
[ROUTES.METER_EXPLORER]: {
queryTypes: [EQueryType.QUERY_BUILDER],
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
},
};
const pageSchemas = new Map<string, z.ZodType<unknown>>(
Object.entries(PAGE_QUERY_CONSTRAINTS).map(([route, constraints]) => [
route,
buildPageQuerySchema(constraints as QueryConstraints),
]),
);
export const pageQuerySchemaFor = (
pathname: string,
): z.ZodType<unknown> | undefined => pageSchemas.get(pathname);

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
@@ -6,15 +7,24 @@ 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 './compositeQuerySchema';
import { pageQuerySchemaFor } from './pageQueryConstraints';
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) {
@@ -27,13 +37,10 @@ export const useGetCompositeQueryParam = (): Query | null => {
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 ?? [];
}
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
parsedCompositeQuery,
pageQuerySchemaFor(pathname),
));
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
@@ -51,7 +58,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 +84,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');
});
});