hyperdx/packages/app/src/__tests__/DBSearchPageQueryKey.test.tsx
Drew Davis 0c16a4b3cf
feat: Align date ranges to MV Granularity (#1575)
Closes HDX-3124

# Summary

This PR makes the following changes

1. Date ranges for all MV queries are now aligned to the MV Granularity
2. Each chart type now has an indicator when the date range has been adjusted to align with either the MV Granularity or (in the case of Line/Bar charts) the Chart Granularity.
3. The useQueriedChartConfig, useRenderedSqlChartConfig, and useOffsetPaginatedQuery hooks have been updated to get the MV-optimized chart configuration from the useMVOptimizationExplanation, which allows us to share the `EXPLAIN ESTIMATE` query results  between the MV Optimization Indicator (the lightning bolt icon on each chart) and the chart itself. This roughly halves the number of EXPLAIN ESTIMATE queries that are made.

## Demo

<img width="1628" height="1220" alt="Screenshot 2026-01-08 at 11 42 39 AM" src="https://github.com/user-attachments/assets/80a06e3a-bbfc-4193-b6b7-5e0056c588d3" />
<img width="1627" height="1131" alt="Screenshot 2026-01-08 at 11 40 54 AM" src="https://github.com/user-attachments/assets/69879e3d-3a83-4c4d-9604-0552a01c17d7" />

## Testing

To test locally with an MV, you can use the following DDL

<details>
<summary>DDL For an MV</summary>

```sql
CREATE TABLE default.metrics_rollup_1m
(
   `Timestamp` DateTime,
   `ServiceName` LowCardinality(String),
   `SpanKind` LowCardinality(String),
   `StatusCode` LowCardinality(String),
   `count` SimpleAggregateFunction(sum, UInt64),
   `sum__Duration` SimpleAggregateFunction(sum, UInt64),
   `avg__Duration` AggregateFunction(avg, UInt64),
   `quantile__Duration` AggregateFunction(quantileTDigest(0.5), UInt64),
   `min__Duration` SimpleAggregateFunction(min, UInt64),
   `max__Duration` SimpleAggregateFunction(max, UInt64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (Timestamp, StatusCode, SpanKind, ServiceName)
SETTINGS index_granularity = 8192;

CREATE MATERIALIZED VIEW default.metrics_rollup_1m_mv TO default.metrics_rollup_1m
(
    `Timestamp` DateTime,
    `ServiceName` LowCardinality(String),
    `SpanKind` LowCardinality(String),
    `version` LowCardinality(String),
    `StatusCode` LowCardinality(String),
    `count` UInt64,
    `sum__Duration` Int64,
    `avg__Duration` AggregateFunction(avg, UInt64),
    `quantile__Duration` AggregateFunction(quantileTDigest(0.5), UInt64),
    `min__Duration` SimpleAggregateFunction(min, UInt64),
    `max__Duration` SimpleAggregateFunction(max, UInt64)
)
AS SELECT
    toStartOfMinute(Timestamp) AS Timestamp,
    ServiceName,
    SpanKind,
    StatusCode,
    count() AS count,
    sum(Duration) AS sum__Duration,
    avgState(Duration) AS avg__Duration,
    quantileTDigestState(0.5)(Duration) AS quantile__Duration,
    minSimpleState(Duration) AS min__Duration,
    maxSimpleState(Duration) AS max__Duration
FROM default.otel_traces
GROUP BY
    Timestamp,
    ServiceName,
    SpanKind,
    StatusCode;
```
</details>
2026-01-09 16:07:52 +00:00

178 lines
5.3 KiB
TypeScript

import React from 'react';
import objectHash from 'object-hash';
import {
ChartConfigWithDateRange,
DisplayType,
} from '@hyperdx/common-utils/dist/types';
import { DBTimeChart } from '@/components/DBTimeChart';
import SearchTotalCountChart from '@/components/SearchTotalCountChart';
// Mock the API and hooks
jest.mock('@/api', () => ({
__esModule: true,
default: {
useMe: () => ({
data: { team: { parallelizeWhenPossible: false } },
isLoading: false,
}),
},
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/hooks/useChartConfig', () => ({
useQueriedChartConfig: jest.fn(() => ({
data: { data: [], isComplete: true },
isLoading: false,
isError: false,
isPlaceholderData: false,
isSuccess: true,
})),
}));
jest.mock('@/source', () => ({
useSource: () => ({ data: null, isLoading: false }),
}));
jest.mock('@/ChartUtils', () => ({
useTimeChartSettings: () => ({
displayType: DisplayType.StackedBar,
dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
granularity: '30 minutes',
fillNulls: true,
}),
formatResponseForTimeChart: () => ({
graphResults: [],
timestampColumn: undefined,
lineData: [],
groupColumns: [],
valueColumns: [],
isSingleValueColumn: true,
}),
getPreviousDateRange: (dateRange: [Date, Date]) => [
new Date('2023-12-31'),
new Date('2024-01-01'),
],
getAlignedDateRange: (dateRange: [Date, Date]) => dateRange,
convertToTimeChartConfig:
jest.requireActual('@/ChartUtils').convertToTimeChartConfig,
}));
describe('DBSearchPage QueryKey Consistency', () => {
let mockUseQueriedChartConfig: jest.Mock;
beforeEach(async () => {
mockUseQueriedChartConfig = (await import('@/hooks/useChartConfig'))
.useQueriedChartConfig as any;
mockUseQueriedChartConfig.mockClear();
});
it('should use matching queryKeys between SearchTotalCountChart and DBTimeChart', () => {
const config: ChartConfigWithDateRange = {
select: 'count()',
from: { databaseName: 'test', tableName: 'logs' },
where: '',
timestampValueExpression: 'timestamp',
connection: 'test-connection',
displayType: DisplayType.StackedBar,
dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
};
const queryKeyPrefix = 'search';
// Render SearchTotalCountChart
renderWithMantine(
<SearchTotalCountChart
config={config}
queryKeyPrefix={queryKeyPrefix}
enableParallelQueries={true}
/>,
);
// Render DBTimeChart
renderWithMantine(
<DBTimeChart
config={config}
queryKeyPrefix={queryKeyPrefix}
enableParallelQueries={true}
/>,
);
// Get all calls to useQueriedChartConfig
const calls = mockUseQueriedChartConfig.mock.calls;
// Should have at least 2 calls (one for each component)
expect(calls.length).toBeGreaterThanOrEqual(2);
// Extract queryKey from each call
const searchTotalCountQueryKey = calls[0][1]?.queryKey;
const dbTimeChartQueryKey = calls[1][1]?.queryKey;
// Both should exist
expect(searchTotalCountQueryKey).toBeDefined();
expect(dbTimeChartQueryKey).toBeDefined();
// The key structure should be identical for both components
// This ensures React Query can properly dedupe the queries
expect(searchTotalCountQueryKey).toEqual(dbTimeChartQueryKey);
// Additional object hash check for deep equality verification
const searchQueryKeyHash = objectHash(searchTotalCountQueryKey);
const chartQueryKeyHash = objectHash(dbTimeChartQueryKey);
expect(searchQueryKeyHash).toBe(chartQueryKeyHash);
});
it('should use consistent queryKeys when disableQueryChunking is set', () => {
const config: ChartConfigWithDateRange = {
select: 'count()',
from: { databaseName: 'test', tableName: 'logs' },
where: '',
timestampValueExpression: 'timestamp',
connection: 'test-connection',
displayType: DisplayType.StackedBar,
dateRange: [new Date('2024-01-01'), new Date('2024-01-02')],
};
const queryKeyPrefix = 'search';
// Render both components with disableQueryChunking
renderWithMantine(
<SearchTotalCountChart
config={config}
queryKeyPrefix={queryKeyPrefix}
disableQueryChunking={true}
/>,
);
renderWithMantine(
<DBTimeChart
config={config}
queryKeyPrefix={queryKeyPrefix}
disableQueryChunking={true}
/>,
);
const calls = mockUseQueriedChartConfig.mock.calls;
const searchQueryKey = calls[0][1]?.queryKey;
const chartQueryKey = calls[1][1]?.queryKey;
// Verify the options include disableQueryChunking
expect(searchQueryKey[3]).toHaveProperty('disableQueryChunking', true);
expect(chartQueryKey[3]).toHaveProperty('disableQueryChunking', true);
// Keys should still match
expect(searchQueryKey).toEqual(chartQueryKey);
// Additional object hash check for deep equality verification
const searchQueryKeyHash = objectHash(searchQueryKey);
const chartQueryKeyHash = objectHash(chartQueryKey);
expect(searchQueryKeyHash).toBe(chartQueryKeyHash);
});
});