mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-13 04:10:27 +01:00
Compare commits
3 Commits
main
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c76d68393 | ||
|
|
ac1321b7dd | ||
|
|
75f410b5ad |
@@ -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',
|
||||
|
||||
@@ -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 { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -294,11 +295,11 @@ function collectSharedMetadata(
|
||||
// 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.
|
||||
// Decode through the serializer seam (handles every tier + malformed
|
||||
// input → null); never JSON.parse the raw URL value.
|
||||
const decodedQuery = deserialize(compositeQueryRaw);
|
||||
if (decodedQuery) {
|
||||
out.query = decodedQuery;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
@@ -299,7 +300,7 @@ function DateTimeSelection({
|
||||
})),
|
||||
},
|
||||
};
|
||||
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
|
||||
return serialize(updatedCompositeQuery);
|
||||
}, [currentQuery]);
|
||||
|
||||
const onSelectHandler = useCallback(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,7 @@
|
||||
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 => {
|
||||
@@ -14,59 +9,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
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;
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
return deserialize(compositeQuery);
|
||||
}, [urlQuery]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { isEqual } from 'lodash-es';
|
||||
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 {
|
||||
@@ -38,16 +39,20 @@ const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded1 = JSON.parse(decodeURIComponent(query1));
|
||||
const decoded2 = JSON.parse(decodeURIComponent(query2));
|
||||
// Decode through the serializer seam: the URL value may be in any
|
||||
// tier (legacy JSON or a tagged format), so never JSON.parse it raw.
|
||||
const decoded1 = deserialize(query1);
|
||||
const decoded2 = deserialize(query2);
|
||||
|
||||
const filtered1 = cloneDeep(decoded1);
|
||||
const filtered2 = cloneDeep(decoded2);
|
||||
if (!decoded1 || !decoded2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete filtered1.id;
|
||||
delete filtered2.id;
|
||||
// Ignore the volatile `id` when comparing queries.
|
||||
const { id: _id1, ...rest1 } = decoded1;
|
||||
const { id: _id2, ...rest2 } = decoded2;
|
||||
|
||||
return isEqual(filtered1, filtered2);
|
||||
return isEqual(rest1, rest2);
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
//
|
||||
// ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
// ║ ⚠️ NEVER UPDATE THESE ⚠️ ║
|
||||
// ╠════════════════════════════════════════════════════════════════════════════╣
|
||||
// ║ These snapshots guard URL backward compatibility. Every emitted URL ║
|
||||
// ║ encodes a diff against these exact baselines. ║
|
||||
// ║ ║
|
||||
// ║ If a test fails: REVERT baseline.ts, do NOT update snapshots. ║
|
||||
// ║ If you need a new schema: create BASELINE_V2 + new adapter prefix. ║
|
||||
// ╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
exports[`baseline immutability snapshots LOGS_BASELINE 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 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 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": "",
|
||||
}
|
||||
`;
|
||||
123
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
123
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ ⚠️ 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, {
|
||||
LOGS_BASELINE,
|
||||
METRICS_BASELINE,
|
||||
pickBaseline,
|
||||
TRACES_BASELINE,
|
||||
} from '../baseline';
|
||||
|
||||
describe('baseline immutability snapshots', () => {
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('METRICS_BASELINE must never change', () => {
|
||||
expect(METRICS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('LOGS_BASELINE must never change', () => {
|
||||
expect(LOGS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('TRACES_BASELINE must never change', () => {
|
||||
expect(TRACES_BASELINE).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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaselineByTag', () => {
|
||||
it('returns LOGS_BASELINE for tag "l"', () => {
|
||||
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE);
|
||||
});
|
||||
|
||||
it('returns TRACES_BASELINE for tag "t"', () => {
|
||||
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE);
|
||||
});
|
||||
|
||||
it('returns METRICS_BASELINE for tag "m"', () => {
|
||||
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
40
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { deserialize, serialize } from 'lib/compositeQuery/serializer';
|
||||
|
||||
describe('composite query serializer', () => {
|
||||
it('serialize picks shortest format', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const serialized = serialize(query);
|
||||
const jsonSerialized = encodeURIComponent(JSON.stringify(query));
|
||||
// Serializer should pick a format shorter than or equal to raw JSON
|
||||
expect(serialized.length).toBeLessThanOrEqual(jsonSerialized.length);
|
||||
// Should use a tagged format with baseline indicator (m/l/t)
|
||||
const usesTaggedFormat =
|
||||
/^V1[mlt]~/.test(serialized) ||
|
||||
/^TV[mlt]~/.test(serialized) ||
|
||||
/^FV[mlt]~/.test(serialized) ||
|
||||
/^FK[mlt]~/.test(serialized);
|
||||
expect(usesTaggedFormat).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(deserialize('%7Bnot-json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty/missing value', () => {
|
||||
expect(deserialize('')).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');
|
||||
});
|
||||
});
|
||||
166
frontend/src/lib/compositeQuery/adapters/flatKeys/codec.ts
Normal file
166
frontend/src/lib/compositeQuery/adapters/flatKeys/codec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Flat-keys codec: a baseline-diff serializer that shortens keys but keeps
|
||||
* values as raw JSON. Simpler than flat-leaf — no field-aware enum compression.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<escapedJSON> e.g. b.qd.0.ds_"logs"
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import { INVERSE_KEY_MAP, KEY_MAP } from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).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 escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json): string {
|
||||
if (value === undefined) {
|
||||
return escapeValue('null');
|
||||
}
|
||||
return escapeValue(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string): Json {
|
||||
return JSON.parse(unescapeValue(token));
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value)}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatKeys, encodeFlatKeys, flatKeysAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatKeysAdapter.decode(flatKeysAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatKeysAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FKm~/FKl~/FKt~', () => {
|
||||
const metricsEncoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FKm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatKeysAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FKl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatKeysAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FKt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatKeysAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatKeysAdapter.matches('FVm~')).toBe(false);
|
||||
expect(flatKeysAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatKeys(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatKeys(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatKeys(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('decodeFlatKeys on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatKeys('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatKeys('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded1);
|
||||
const encoded2 = flatKeysAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
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 encoded1 = flatKeysAdapter.encode(query1);
|
||||
const encoded2 = flatKeysAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
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 encoded = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/src/lib/compositeQuery/adapters/flatKeys/index.ts
Normal file
32
frontend/src/lib/compositeQuery/adapters/flatKeys/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FK';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-keys (FK~): stores a query as a keys-only, leaf-flattened diff from the
|
||||
* frozen baseline. Uses short keys but keeps values as raw JSON (no field-aware
|
||||
* enum compression).
|
||||
*
|
||||
* Useful for benchmarking: isolates contribution of key shortening from value compression.
|
||||
*/
|
||||
export const flatKeysAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-keys',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatKeys, decode as decodeFlatKeys } from './codec';
|
||||
51
frontend/src/lib/compositeQuery/adapters/flatKeys/maps.ts
Normal file
51
frontend/src/lib/compositeQuery/adapters/flatKeys/maps.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Key shortening map for flatKeys adapter.
|
||||
* No value compression - only key shortening.
|
||||
*/
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/codec.ts
Normal file
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/codec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Flat-leaf codec: a baseline-diff serializer that emits one token per changed
|
||||
* scalar leaf, with no JSON wrapper characters on the wire.
|
||||
*
|
||||
* Both the baseline and the query are flattened to maps of
|
||||
* `{ shortDotPath: scalarLeaf }` (arrays walked element-wise, so `queryData[0]`
|
||||
* becomes `qd.0`). Encode diffs the two maps and emits a token per changed or
|
||||
* removed leaf; decode replays those tokens onto the baseline map and rebuilds
|
||||
* the nested object. Because arrays are walked element-wise, adding one filter
|
||||
* costs a few scalar tokens instead of a whole-array JSON blob.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<encodedLeaf> e.g. b.qd.0.ds_0
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import {
|
||||
FIELD_DOMAINS,
|
||||
INVERSE_FIELD_DOMAINS,
|
||||
INVERSE_KEY_MAP,
|
||||
KEY_MAP,
|
||||
} from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
const lastSeg = (path: string): string => path.slice(path.lastIndexOf('.') + 1);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).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 escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json, shortKey: string): string {
|
||||
if (typeof value === 'string') {
|
||||
const domain = FIELD_DOMAINS[shortKey];
|
||||
if (domain?.[value] !== undefined) {
|
||||
return String(domain[value]);
|
||||
}
|
||||
return escapeValue(value);
|
||||
}
|
||||
if (value === undefined) {
|
||||
return '!null';
|
||||
}
|
||||
return `!${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string, shortKey: string): Json {
|
||||
if (token[0] === '!') {
|
||||
return JSON.parse(token.slice(1));
|
||||
}
|
||||
const inverse = INVERSE_FIELD_DOMAINS[shortKey];
|
||||
if (inverse && isIndex(token)) {
|
||||
const mapped = inverse[Number(token)];
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return unescapeValue(token);
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value, lastSeg(path))}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1), lastSeg(path));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatLeaf, encodeFlatLeaf, flatLeafAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatLeafAdapter.decode(flatLeafAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatLeafAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FVm~/FVl~/FVt~', () => {
|
||||
const metricsEncoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FVm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatLeafAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FVl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatLeafAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FVt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatLeafAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatLeafAdapter.matches('V1m~[]')).toBe(false);
|
||||
expect(flatLeafAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatLeaf(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('emits no payload when query equals baseline shape', () => {
|
||||
const { payload, tag } = encodeFlatLeaf(initialQueriesMap.logs);
|
||||
expect(tag).toBe('l');
|
||||
expect(decodeFlatLeaf(payload, tag)).toStrictEqual(initialQueriesMap.logs);
|
||||
});
|
||||
|
||||
it('decodeFlatLeaf on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatLeaf('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatLeaf('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded1);
|
||||
const encoded2 = flatLeafAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
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 encoded1 = flatLeafAdapter.encode(query1);
|
||||
const encoded2 = flatLeafAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('stable after spread/reconstruct', () => {
|
||||
const query = { ...initialQueriesMap.metrics };
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
|
||||
const transformed = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const encoded2 = flatLeafAdapter.encode(transformed);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key validation', () => {
|
||||
it('decoded query has all keys expected by replaceIncorrectObjectFields', () => {
|
||||
const decoded = flatLeafAdapter.decode(
|
||||
flatLeafAdapter.encode(initialQueriesMap.metrics),
|
||||
);
|
||||
const decodedKeys = Object.keys(decoded).sort();
|
||||
const expectedKeys = Object.keys(initialQueriesMap.metrics).sort();
|
||||
|
||||
expect(decodedKeys).toStrictEqual(expectedKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEqual compatibility', () => {
|
||||
it('lodash isEqual works for decoded queries', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
|
||||
const { id: _id1, ...rest1 } = query;
|
||||
const { id: _id2, ...rest2 } = decoded;
|
||||
|
||||
expect(isEqual(rest1, rest2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
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 encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/compositeQuery/adapters/flatLeaf/index.ts
Normal file
30
frontend/src/lib/compositeQuery/adapters/flatLeaf/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FV';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-leaf (FV~): stores a query as a field-aware, leaf-flattened diff from the
|
||||
* frozen baseline. Uses the smart hybrid strategy — metrics or logs baseline
|
||||
* picked by dataSource — so the tag is FVm~ (metrics) or FVl~ (logs).
|
||||
*/
|
||||
export const flatLeafAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-leaf',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatLeaf, decode as decodeFlatLeaf } from './codec';
|
||||
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/maps.ts
Normal file
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/maps.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AutocompleteType,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
DataSource,
|
||||
LogsAggregatorOperator,
|
||||
MetricAggregateOperator,
|
||||
ReduceOperators,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
|
||||
const DATA_SOURCE: Record<DataSource, number> = {
|
||||
[DataSource.LOGS]: 0,
|
||||
[DataSource.METRICS]: 1,
|
||||
[DataSource.TRACES]: 2,
|
||||
};
|
||||
|
||||
const QUERY_TYPE: Record<EQueryType, number> = {
|
||||
[EQueryType.QUERY_BUILDER]: 0,
|
||||
[EQueryType.PROM]: 1,
|
||||
[EQueryType.CLICKHOUSE]: 2,
|
||||
};
|
||||
|
||||
const REDUCE_TO: Record<ReduceOperators, number> = {
|
||||
[ReduceOperators.LAST]: 0,
|
||||
[ReduceOperators.SUM]: 1,
|
||||
[ReduceOperators.AVG]: 2,
|
||||
[ReduceOperators.MAX]: 3,
|
||||
[ReduceOperators.MIN]: 4,
|
||||
};
|
||||
|
||||
const TEMPORALITY: Record<MetrictypesTemporalityDTO, number> = {
|
||||
[MetrictypesTemporalityDTO.delta]: 0,
|
||||
[MetrictypesTemporalityDTO.cumulative]: 1,
|
||||
[MetrictypesTemporalityDTO.unspecified]: 2,
|
||||
};
|
||||
|
||||
const TIME_AGGREGATION: Record<MetrictypesTimeAggregationDTO, number> = {
|
||||
[MetrictypesTimeAggregationDTO.latest]: 0,
|
||||
[MetrictypesTimeAggregationDTO.sum]: 1,
|
||||
[MetrictypesTimeAggregationDTO.avg]: 2,
|
||||
[MetrictypesTimeAggregationDTO.min]: 3,
|
||||
[MetrictypesTimeAggregationDTO.max]: 4,
|
||||
[MetrictypesTimeAggregationDTO.count]: 5,
|
||||
[MetrictypesTimeAggregationDTO.count_distinct]: 6,
|
||||
[MetrictypesTimeAggregationDTO.rate]: 7,
|
||||
[MetrictypesTimeAggregationDTO.increase]: 8,
|
||||
};
|
||||
|
||||
const SPACE_AGGREGATION: Record<MetrictypesSpaceAggregationDTO, number> = {
|
||||
[MetrictypesSpaceAggregationDTO.sum]: 0,
|
||||
[MetrictypesSpaceAggregationDTO.avg]: 1,
|
||||
[MetrictypesSpaceAggregationDTO.min]: 2,
|
||||
[MetrictypesSpaceAggregationDTO.max]: 3,
|
||||
[MetrictypesSpaceAggregationDTO.count]: 4,
|
||||
[MetrictypesSpaceAggregationDTO.p50]: 5,
|
||||
[MetrictypesSpaceAggregationDTO.p75]: 6,
|
||||
[MetrictypesSpaceAggregationDTO.p90]: 7,
|
||||
[MetrictypesSpaceAggregationDTO.p95]: 8,
|
||||
[MetrictypesSpaceAggregationDTO.p99]: 9,
|
||||
};
|
||||
|
||||
const AGGREGATE_OPERATOR: Record<
|
||||
MetricAggregateOperator | TracesAggregatorOperator | LogsAggregatorOperator,
|
||||
number
|
||||
> = {
|
||||
[MetricAggregateOperator.EMPTY]: 0,
|
||||
[MetricAggregateOperator.NOOP]: 1,
|
||||
[MetricAggregateOperator.COUNT]: 2,
|
||||
[MetricAggregateOperator.COUNT_DISTINCT]: 3,
|
||||
[MetricAggregateOperator.SUM]: 4,
|
||||
[MetricAggregateOperator.AVG]: 5,
|
||||
[MetricAggregateOperator.MAX]: 6,
|
||||
[MetricAggregateOperator.MIN]: 7,
|
||||
[MetricAggregateOperator.P05]: 8,
|
||||
[MetricAggregateOperator.P10]: 9,
|
||||
[MetricAggregateOperator.P20]: 10,
|
||||
[MetricAggregateOperator.P25]: 11,
|
||||
[MetricAggregateOperator.P50]: 12,
|
||||
[MetricAggregateOperator.P75]: 13,
|
||||
[MetricAggregateOperator.P90]: 14,
|
||||
[MetricAggregateOperator.P95]: 15,
|
||||
[MetricAggregateOperator.P99]: 16,
|
||||
[MetricAggregateOperator.RATE]: 17,
|
||||
[MetricAggregateOperator.SUM_RATE]: 18,
|
||||
[MetricAggregateOperator.AVG_RATE]: 19,
|
||||
[MetricAggregateOperator.MAX_RATE]: 20,
|
||||
[MetricAggregateOperator.MIN_RATE]: 21,
|
||||
[MetricAggregateOperator.RATE_SUM]: 22,
|
||||
[MetricAggregateOperator.RATE_AVG]: 23,
|
||||
[MetricAggregateOperator.RATE_MIN]: 24,
|
||||
[MetricAggregateOperator.RATE_MAX]: 25,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_50]: 26,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_75]: 27,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_90]: 28,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_95]: 29,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_99]: 30,
|
||||
[MetricAggregateOperator.INCREASE]: 31,
|
||||
[MetricAggregateOperator.LATEST]: 32,
|
||||
};
|
||||
|
||||
const DATA_TYPE: Record<DataTypes, number> = {
|
||||
[DataTypes.Int64]: 0,
|
||||
[DataTypes.String]: 1,
|
||||
[DataTypes.Float64]: 2,
|
||||
[DataTypes.bool]: 3,
|
||||
[DataTypes.ArrayFloat64]: 4,
|
||||
[DataTypes.ArrayInt64]: 5,
|
||||
[DataTypes.ArrayString]: 6,
|
||||
[DataTypes.ArrayBool]: 7,
|
||||
[DataTypes.EMPTY]: 8,
|
||||
};
|
||||
|
||||
const ATTR_TYPE: Record<AutocompleteType, number> = {
|
||||
tag: 0,
|
||||
resource: 1,
|
||||
'': 2,
|
||||
};
|
||||
|
||||
export const FIELD_DOMAINS: Record<string, Record<string, number>> = {
|
||||
ds: DATA_SOURCE,
|
||||
qt: QUERY_TYPE,
|
||||
rt: REDUCE_TO,
|
||||
tp: TEMPORALITY,
|
||||
ta: TIME_AGGREGATION,
|
||||
sa: SPACE_AGGREGATION,
|
||||
ao: AGGREGATE_OPERATOR,
|
||||
dt: DATA_TYPE,
|
||||
t: ATTR_TYPE,
|
||||
};
|
||||
|
||||
export const INVERSE_FIELD_DOMAINS: Record<
|
||||
string,
|
||||
Record<number, string>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(FIELD_DOMAINS).map(([field, domain]) => [
|
||||
field,
|
||||
Object.fromEntries(
|
||||
Object.entries(domain).map(([str, int]) => [int, str]),
|
||||
) as Record<number, string>,
|
||||
]),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
55
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
55
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
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) => encodeURIComponent(JSON.stringify(query)),
|
||||
matches: () => true,
|
||||
decode: (raw) => {
|
||||
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
||||
return migrateLegacyFormat(parsed);
|
||||
},
|
||||
};
|
||||
68
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
68
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
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 encoded = jsonAdapter.encode(query);
|
||||
|
||||
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
|
||||
expect(encoded.startsWith('%7B')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('matches any value (catch-all fallback)', () => {
|
||||
expect(jsonAdapter.matches('%7B%22queryType%22%3A%22builder%22%7D')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(jsonAdapter.matches('z1~abc')).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 raw = encodeURIComponent(JSON.stringify(legacy));
|
||||
const decoded = jsonAdapter.decode(raw);
|
||||
expect(decoded.builder.queryData[0].filter).toBeDefined();
|
||||
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/compositeQuery/adapters/json/types.ts
Normal file
8
frontend/src/lib/compositeQuery/adapters/json/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -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() },
|
||||
];
|
||||
179
frontend/src/lib/compositeQuery/baseline.ts
Normal file
179
frontend/src/lib/compositeQuery/baseline.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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 = {
|
||||
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;
|
||||
|
||||
/** Baseline for logs/traces queries — uses expression-style aggregations. */
|
||||
export const LOGS_BASELINE = {
|
||||
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;
|
||||
|
||||
/** Baseline for traces queries — same as logs but with dataSource: traces. */
|
||||
export const TRACES_BASELINE = {
|
||||
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;
|
||||
|
||||
/** 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, tag: 'l' };
|
||||
}
|
||||
if (ds === 'traces') {
|
||||
return { baseline: TRACES_BASELINE, tag: 't' };
|
||||
}
|
||||
return { baseline: METRICS_BASELINE, tag: 'm' };
|
||||
}
|
||||
|
||||
function getBaselineByTag(tag: BaselineTag): Query {
|
||||
if (tag === 'l') {
|
||||
return LOGS_BASELINE;
|
||||
}
|
||||
if (tag === 't') {
|
||||
return TRACES_BASELINE;
|
||||
}
|
||||
return METRICS_BASELINE;
|
||||
}
|
||||
|
||||
export default getBaselineByTag;
|
||||
28
frontend/src/lib/compositeQuery/serializer.ts
Normal file
28
frontend/src/lib/compositeQuery/serializer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
|
||||
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { flatKeysAdapter } from 'lib/compositeQuery/adapters/flatKeys';
|
||||
|
||||
// Order matters for decode: most-specific (tagged) adapters first, json last
|
||||
const ADAPTERS: CompositeQueryAdapter[] = [flatKeysAdapter, jsonAdapter];
|
||||
|
||||
/** Encode a query to the shortest available URL value. */
|
||||
export function serialize(query: Query): string {
|
||||
return ADAPTERS.map((adapter) => adapter.encode(query)).reduce(
|
||||
(shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest,
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode a URL value back to a Query. Total: returns null on any failure. */
|
||||
export function deserialize(raw: string): Query | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const adapter = ADAPTERS.find((item) => item.matches(raw)) ?? jsonAdapter;
|
||||
return adapter.decode(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
14
frontend/src/lib/compositeQuery/types.ts
Normal file
14
frontend/src/lib/compositeQuery/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* A serialization tier. `encode` returns the COMPLETE URL value (tag prefix
|
||||
* included for tagged tiers). `matches` decides whether a raw value belongs to
|
||||
* this adapter on decode. `decode` receives the COMPLETE raw value and strips
|
||||
* its own tag.
|
||||
*/
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
@@ -936,7 +937,7 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate({
|
||||
preventSameUrlNavigation: false,
|
||||
preventSameUrlNavigation: true,
|
||||
});
|
||||
|
||||
const redirectWithQueryBuilderData = useCallback(
|
||||
@@ -990,10 +991,7 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
}
|
||||
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
|
||||
);
|
||||
urlQuery.set(QueryParams.compositeQuery, serialize(currentGeneratedQuery));
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
|
||||
Reference in New Issue
Block a user