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>
This commit is contained in:
Drew Davis 2026-01-09 11:07:52 -05:00 committed by GitHub
parent 9f9629e4cf
commit 0c16a4b3cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1518 additions and 224 deletions

View file

@ -0,0 +1,6 @@
---
"@hyperdx/common-utils": patch
"@hyperdx/app": patch
---
feat: Align date ranges to MV Granularity

View file

@ -10,8 +10,10 @@
"@changesets/cli": "^2.26.2",
"@dotenvx/dotenvx": "^1.51.1",
"@nx/workspace": "21.3.11",
"@types/ungap__structured-clone": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"@ungap/structured-clone": "^1.3.0",
"babel-plugin-react-compiler": "^1.0.0",
"concurrently": "^9.1.2",
"dotenv": "^16.4.7",

View file

@ -11,6 +11,7 @@ import {
ResponseJSON,
} from '@hyperdx/common-utils/dist/clickhouse';
import { isMetricChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
import {
AggregateFunction as AggFnV2,
ChartConfigWithDateRange,
@ -26,6 +27,8 @@ import {
import { SegmentedControl } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import DateRangeIndicator from './components/charts/DateRangeIndicator';
import { MVOptimizationExplanationResult } from './hooks/useMVOptimizationExplanation';
import { getMetricNameSql } from './otelSemanticConventions';
import {
AggFn,
@ -142,23 +145,6 @@ export const isGranularity = (value: string): value is Granularity => {
return Object.values(Granularity).includes(value as Granularity);
};
export function getAlignedDateRange(
[originalStart, originalEnd]: [Date, Date],
granularity: SQLInterval,
): [Date, Date] {
// Round the start time down to the previous interval boundary
const alignedStart = toStartOfInterval(originalStart, granularity);
// Round the end time up to the next interval boundary
let alignedEnd = toStartOfInterval(originalEnd, granularity);
if (alignedEnd.getTime() < originalEnd.getTime()) {
const intervalSeconds = convertGranularityToSeconds(granularity);
alignedEnd = add(alignedEnd, { seconds: intervalSeconds });
}
return [alignedStart, alignedEnd];
}
export function convertToTimeChartConfig(config: ChartConfigWithDateRange) {
const granularity =
config.granularity === 'auto' || config.granularity == null
@ -1206,3 +1192,26 @@ export function convertToTableChartConfig(
return convertedConfig;
}
export function buildMVDateRangeIndicator({
mvOptimizationData,
originalDateRange,
}: {
mvOptimizationData?: MVOptimizationExplanationResult;
originalDateRange: [Date, Date];
}) {
const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange;
if (!mvDateRange) return null;
const mvGranularity = mvOptimizationData?.explanations.find(e => e.success)
?.mvConfig.minGranularity;
return (
<DateRangeIndicator
key="date-range-indicator"
originalDateRange={originalDateRange}
effectiveDateRange={mvDateRange}
mvGranularity={mvGranularity}
/>
);
}

View file

@ -1847,6 +1847,7 @@ function DBSearchPage() {
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
/>
@ -1921,6 +1922,7 @@ function DBSearchPage() {
enabled={isReady}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
queryKeyPrefix={QUERY_KEY_PREFIX}
onTimeRangeSelect={handleTimeRangeSelect}
enableParallelQueries

View file

@ -9,15 +9,8 @@ import {
convertToTableChartConfig,
convertToTimeChartConfig,
formatResponseForTimeChart,
getAlignedDateRange,
} from '@/ChartUtils';
if (!globalThis.structuredClone) {
globalThis.structuredClone = (obj: any) => {
return JSON.parse(JSON.stringify(obj));
};
}
describe('ChartUtils', () => {
describe('formatResponseForTimeChart', () => {
it('should throw an error if there is no timestamp column', () => {
@ -654,111 +647,4 @@ describe('ChartUtils', () => {
expect(convertedConfig.limit).toEqual({ limit: 200 });
});
});
describe('getAlignedDateRange', () => {
it('should align start time down to the previous minute boundary', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:37Z'), // 37 seconds
new Date('2025-11-26T12:25:00Z'),
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
});
it('should align end time up to the next minute boundary', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:00Z'),
new Date('2025-11-26T12:25:42Z'), // 42 seconds
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
});
it('should align both start and end times with 5 minute granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:20:00
new Date('2025-11-26T12:27:42Z'), // Should round up to 12:30:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'5 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:20:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:30:00.000Z');
});
it('should align with 30 second granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:23:00
new Date('2025-11-26T12:25:42Z'), // Should round up to 12:26:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'30 second',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
});
it('should align with 1 day granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to start of day
new Date('2025-11-28T08:15:00Z'), // Should round up to start of next day
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 day',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T00:00:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-29T00:00:00.000Z');
});
it('should not change range when already aligned to the interval', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:00Z'), // Already aligned
new Date('2025-11-26T12:25:00Z'), // Already aligned
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
});
it('should align with 15 minute granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:15:00
new Date('2025-11-26T12:47:42Z'), // Should round up to 13:00:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'15 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:15:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T13:00:00.000Z');
});
});
});

View file

@ -19,6 +19,14 @@ jest.mock('@/api', () => ({
},
}));
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 },

View file

@ -61,6 +61,7 @@ export const AlertPreviewChart = ({
sourceId={source.id}
showDisplaySwitcher={false}
showMVOptimizationIndicator={false}
showDateRangeIndicator={false}
referenceLines={getAlertReferenceLines({ threshold, thresholdType })}
config={{
where: where || '',

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useMemo, useRef } from 'react';
import Link from 'next/link';
import { useHotkeys } from 'react-hotkeys-hook';
import {
@ -14,7 +14,9 @@ import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { Box, Code, Text } from '@mantine/core';
import { buildMVDateRangeIndicator } from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import { omit } from '@/utils';
@ -216,6 +218,9 @@ export default function DBHistogramChart({
},
);
const { data: mvOptimizationData } =
useMVOptimizationExplanation(queriedConfig);
// Don't ask me why...
const buckets = data?.data?.[0]?.data;
@ -232,24 +237,34 @@ export default function DBHistogramChart({
allToolbarItems.push(
<MVOptimizationIndicator
key="db-histogram-chart-mv-indicator"
config={config}
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
const dateRangeIndicator = buildMVDateRangeIndicator({
mvOptimizationData,
originalDateRange: queriedConfig.dateRange,
});
if (dateRangeIndicator) {
allToolbarItems.push(dateRangeIndicator);
}
if (toolbarSuffix && toolbarSuffix.length > 0) {
allToolbarItems.push(...toolbarSuffix);
}
return allToolbarItems;
}, [
config,
queriedConfig,
toolbarPrefix,
toolbarSuffix,
source,
showMVOptimizationIndicator,
mvOptimizationData,
]);
return (

View file

@ -5,7 +5,9 @@ import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import type { FloatingPosition } from '@mantine/core';
import { Box, Code, Flex, HoverCard, Text } from '@mantine/core';
import { buildMVDateRangeIndicator } from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import type { NumberFormat } from '@/types';
import { omit } from '@/utils';
@ -206,6 +208,9 @@ export default function DBListBarChart({
},
);
const { data: mvOptimizationData } =
useMVOptimizationExplanation(queriedConfig);
const { data: source } = useSource({ id: config.source });
const columns = useMemo(() => {
@ -230,19 +235,34 @@ export default function DBListBarChart({
allToolbarItems.push(
<MVOptimizationIndicator
key="db-list-bar-chart-mv-indicator"
config={config}
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
const dateRangeIndicator = buildMVDateRangeIndicator({
mvOptimizationData,
originalDateRange: queriedConfig.dateRange,
});
if (dateRangeIndicator) {
allToolbarItems.push(dateRangeIndicator);
}
if (toolbarItems && toolbarItems.length > 0) {
allToolbarItems.push(...toolbarItems);
}
return allToolbarItems;
}, [config, source, toolbarItems, showMVOptimizationIndicator]);
}, [
queriedConfig,
source,
toolbarItems,
showMVOptimizationIndicator,
mvOptimizationData,
]);
return (
<ChartContainer title={title} toolbarItems={toolbarItemsMemo}>

View file

@ -3,8 +3,12 @@ import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { Box, Code, Flex, Text } from '@mantine/core';
import { convertToNumberChartConfig } from '@/ChartUtils';
import {
buildMVDateRangeIndicator,
convertToNumberChartConfig,
} from '@/ChartUtils';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import { formatNumber } from '@/utils';
@ -34,6 +38,9 @@ export default function DBNumberChart({
[config],
);
const { data: mvOptimizationData } =
useMVOptimizationExplanation(queriedConfig);
const { data, isLoading, isError, error } = useQueriedChartConfig(
queriedConfig,
{
@ -61,24 +68,34 @@ export default function DBNumberChart({
allToolbarItems.push(
<MVOptimizationIndicator
key="db-number-chart-mv-indicator"
config={config}
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
const dateRangeIndicator = buildMVDateRangeIndicator({
mvOptimizationData,
originalDateRange: queriedConfig.dateRange,
});
if (dateRangeIndicator) {
allToolbarItems.push(dateRangeIndicator);
}
if (toolbarSuffix && toolbarSuffix.length > 0) {
allToolbarItems.push(...toolbarSuffix);
}
return allToolbarItems;
}, [
config,
toolbarPrefix,
toolbarSuffix,
source,
showMVOptimizationIndicator,
mvOptimizationData,
queriedConfig,
]);
return (

View file

@ -1,14 +1,15 @@
import { useCallback, useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
ChartConfigWithDateRange,
ChartConfigWithOptTimestamp,
} from '@hyperdx/common-utils/dist/types';
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
import { Box, Code, Text } from '@mantine/core';
import { SortingState } from '@tanstack/react-table';
import { convertToTableChartConfig } from '@/ChartUtils';
import {
buildMVDateRangeIndicator,
convertToTableChartConfig,
} from '@/ChartUtils';
import { Table } from '@/HDXMultiSeriesTableChart';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import { useSource } from '@/source';
import { useIntersectionObserver } from '@/utils';
@ -76,8 +77,11 @@ export default function DBTableChart({
return _config;
}, [config, effectiveSort]);
const { data: mvOptimizationData } =
useMVOptimizationExplanation(queriedConfig);
const { data, fetchNextPage, hasNextPage, isLoading, isError, error } =
useOffsetPaginatedQuery(queriedConfig as ChartConfigWithDateRange, {
useOffsetPaginatedQuery(queriedConfig, {
enabled,
queryKeyPrefix,
});
@ -139,24 +143,34 @@ export default function DBTableChart({
allToolbarItems.push(
<MVOptimizationIndicator
key="db-table-chart-mv-indicator"
config={config}
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
const dateRangeIndicator = buildMVDateRangeIndicator({
mvOptimizationData,
originalDateRange: queriedConfig.dateRange,
});
if (dateRangeIndicator) {
allToolbarItems.push(dateRangeIndicator);
}
if (toolbarSuffix && toolbarSuffix.length > 0) {
allToolbarItems.push(...toolbarSuffix);
}
return allToolbarItems;
}, [
config,
toolbarPrefix,
toolbarSuffix,
source,
showMVOptimizationIndicator,
mvOptimizationData,
queriedConfig,
]);
return (

View file

@ -2,6 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { add, differenceInSeconds } from 'date-fns';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { getAlignedDateRange } from '@hyperdx/common-utils/dist/core/utils';
import {
ChartConfigWithDateRange,
DisplayType,
@ -22,7 +23,6 @@ import {
IconArrowsDiagonal,
IconChartBar,
IconChartLine,
IconClock,
IconSearch,
} from '@tabler/icons-react';
@ -34,16 +34,17 @@ import {
convertGranularityToSeconds,
convertToTimeChartConfig,
formatResponseForTimeChart,
getAlignedDateRange,
getPreviousDateRange,
PreviousPeriodSuffix,
useTimeChartSettings,
} from '@/ChartUtils';
import { MemoChart } from '@/HDXMultiSeriesTimeChart';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import ChartContainer from './charts/ChartContainer';
import DateRangeIndicator from './charts/DateRangeIndicator';
import DisplaySwitcher from './charts/DisplaySwitcher';
import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator';
import { SQLPreview } from './ChartSQLPreview';
@ -220,6 +221,7 @@ type DBTimeChartComponentProps = {
toolbarPrefix?: React.ReactNode[];
toolbarSuffix?: React.ReactNode[];
showMVOptimizationIndicator?: boolean;
showDateRangeIndicator?: boolean;
};
function DBTimeChartComponent({
@ -241,6 +243,7 @@ function DBTimeChartComponent({
toolbarPrefix,
toolbarSuffix,
showMVOptimizationIndicator = true,
showDateRangeIndicator = true,
}: DBTimeChartComponentProps) {
const [isErrorExpanded, errorExpansion] = useDisclosure(false);
const [selectedSeriesSet, setSelectedSeriesSet] = useState<Set<string>>(
@ -290,6 +293,9 @@ function DBTimeChartComponent({
[config],
);
const { data: mvOptimizationData } =
useMVOptimizationExplanation(queriedConfig);
const { data: me, isLoading: isLoadingMe } = api.useMe();
const { data, isLoading, isError, error, isPlaceholderData, isSuccess } =
useQueriedChartConfig(queriedConfig, {
@ -592,13 +598,36 @@ function DBTimeChartComponent({
allToolbarItems.push(
<MVOptimizationIndicator
key="db-time-chart-mv-indicator"
config={config}
config={queriedConfig}
source={source}
variant="icon"
/>,
);
}
const mvDateRange = mvOptimizationData?.optimizedConfig?.dateRange;
const isAlignedToChartGranularity =
queriedConfig.alignDateRangeToGranularity !== false;
if (
showDateRangeIndicator &&
(mvDateRange || isAlignedToChartGranularity)
) {
const mvGranularity = isAlignedToChartGranularity
? undefined
: mvOptimizationData?.explanations.find(e => e.success)?.mvConfig
.minGranularity;
allToolbarItems.push(
<DateRangeIndicator
key="db-time-chart-date-range-indicator"
originalDateRange={config.dateRange}
effectiveDateRange={mvDateRange || queriedConfig.dateRange}
mvGranularity={mvGranularity}
/>,
);
}
if (showDisplaySwitcher) {
allToolbarItems.push(
<DisplaySwitcher
@ -638,6 +667,9 @@ function DBTimeChartComponent({
toolbarPrefix,
toolbarSuffix,
showMVOptimizationIndicator,
showDateRangeIndicator,
mvOptimizationData,
queriedConfig,
]);
return (

View file

@ -0,0 +1,155 @@
import React from 'react';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import DateRangeIndicator from '../charts/DateRangeIndicator';
import DBHistogramChart from '../DBHistogramChart';
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
// Mock dependencies
jest.mock('@/hooks/useChartConfig', () => ({
useQueriedChartConfig: jest.fn(),
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/source', () => ({
useSource: jest.fn().mockReturnValue({ data: null }),
}));
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
describe('DBHistogramChart', () => {
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
const baseTestConfig = {
dateRange: [new Date(), new Date()] as [Date, Date],
from: { databaseName: 'test', tableName: 'test' },
timestampValueExpression: 'timestamp',
connection: 'test-connection',
select: '',
where: '',
};
beforeEach(() => {
jest.clearAllMocks();
mockUseQueriedChartConfig.mockReturnValue({
data: {
data: [{ bucket: '0-10', count: 5 }],
meta: [],
},
isLoading: false,
isError: false,
});
});
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
// Mock useSource to return a source so MVOptimizationIndicator is rendered
jest.mocked(useSource).mockReturnValue({
data: { id: 'test-source', name: 'Test Source' },
} as any);
renderWithMantine(<DBHistogramChart config={baseTestConfig} />);
// Get the config that was passed to useMVOptimizationExplanation
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
.mock.calls[0][0];
// Get the config that was passed to useQueriedChartConfig
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
.calls[0][0];
// Get the config that was passed to MVOptimizationIndicator
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
.calls[0][0].config;
// All three should receive the same config object reference
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
expect(queriedChartConfig).toBe(indicatorConfig);
expect(mvOptExplanationConfig).toBe(indicatorConfig);
});
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
const config = {
...baseTestConfig,
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: {
...config,
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
},
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 minute',
tableName: 'metrics_rollup_1m',
},
},
],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBHistogramChart config={config} />);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
});
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
// Mock useMVOptimizationExplanation to return data without an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBHistogramChart config={baseTestConfig} />);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,170 @@
import React from 'react';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import DateRangeIndicator from '../charts/DateRangeIndicator';
import DBListBarChart from '../DBListBarChart';
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
// Mock dependencies
jest.mock('@/hooks/useChartConfig', () => ({
useQueriedChartConfig: jest.fn(),
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/source', () => ({
useSource: jest.fn().mockReturnValue({ data: null }),
}));
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
describe('DBListBarChart', () => {
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
const baseTestConfig = {
dateRange: [new Date(), new Date()] as [Date, Date],
from: { databaseName: 'test', tableName: 'test' },
timestampValueExpression: 'timestamp',
connection: 'test-connection',
select: '',
where: '',
};
beforeEach(() => {
jest.clearAllMocks();
mockUseQueriedChartConfig.mockReturnValue({
data: { data: [{ group: 'test', value: 100 }] },
isLoading: false,
isError: false,
});
});
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
// Mock useSource to return a source so MVOptimizationIndicator is rendered
jest.mocked(useSource).mockReturnValue({
data: { id: 'test-source', name: 'Test Source' },
} as any);
renderWithMantine(
<DBListBarChart
config={baseTestConfig}
valueColumn="value"
groupColumn="group"
/>,
);
// Get the config that was passed to useMVOptimizationExplanation
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
.mock.calls[0][0];
// Get the config that was passed to useQueriedChartConfig
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
.calls[0][0];
// Get the config that was passed to MVOptimizationIndicator
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
.calls[0][0].config;
// All three should receive the same config object reference
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
expect(queriedChartConfig).toBe(indicatorConfig);
expect(mvOptExplanationConfig).toBe(indicatorConfig);
});
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
const config = {
...baseTestConfig,
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: {
...config,
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
},
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 minute',
tableName: 'metrics_rollup_1m',
},
},
],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(
<DBListBarChart
config={config}
valueColumn="value"
groupColumn="group"
/>,
);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
});
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
// Mock useMVOptimizationExplanation to return data without an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(
<DBListBarChart
config={baseTestConfig}
valueColumn="value"
groupColumn="group"
/>,
);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -2,16 +2,28 @@ import React from 'react';
import { act, screen } from '@testing-library/react';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import { formatNumber } from '@/utils';
import { NumberFormat } from '../../types';
import DateRangeIndicator from '../charts/DateRangeIndicator';
import DBNumberChart from '../DBNumberChart';
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
// Mock dependencies
jest.mock('@/hooks/useChartConfig', () => ({
useQueriedChartConfig: jest.fn(),
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/source', () => ({
useSource: jest.fn().mockReturnValue({ data: null }),
}));
@ -25,6 +37,12 @@ jest.mock('@/utils', () => ({
}),
}));
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
describe('DBNumberChart', () => {
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
const mockFormatNumber = formatNumber as jest.Mock;
@ -185,4 +203,101 @@ describe('DBNumberChart', () => {
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
expect(screen.getByText(/Error loading chart/)).toBeInTheDocument();
});
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
// Mock useSource to return a source so MVOptimizationIndicator is rendered
jest.mocked(useSource).mockReturnValue({
data: { id: 'test-source', name: 'Test Source' },
} as any);
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
// Get the config that was passed to useMVOptimizationExplanation
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
.mock.calls[0][0];
// Get the config that was passed to useQueriedChartConfig
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
.calls[0][0];
// Get the config that was passed to MVOptimizationIndicator
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
.calls[0][0].config;
// All three should receive the same config object reference
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
expect(queriedChartConfig).toBe(indicatorConfig);
expect(mvOptExplanationConfig).toBe(indicatorConfig);
});
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
const config = {
...baseTestConfig,
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: {
...config,
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
},
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 minute',
tableName: 'metrics_rollup_1m',
},
},
],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBNumberChart config={config} />);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
});
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
// Mock useMVOptimizationExplanation to return data without an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBNumberChart config={baseTestConfig} />);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,168 @@
import React from 'react';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
import { useSource } from '@/source';
import DateRangeIndicator from '../charts/DateRangeIndicator';
import DBTableChart from '../DBTableChart';
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
// Mock dependencies
jest.mock('@/hooks/useOffsetPaginatedQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/source', () => ({
useSource: jest.fn().mockReturnValue({ data: null }),
}));
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
describe('DBTableChart', () => {
const baseTestConfig = {
dateRange: [new Date(), new Date()] as [Date, Date],
from: { databaseName: 'test', tableName: 'test' },
timestampValueExpression: 'timestamp',
connection: 'test-connection',
select: '',
where: '',
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useOffsetPaginatedQuery).mockReturnValue({
data: {
data: [{ column1: 'value1', column2: 'value2' }],
meta: [
{ name: 'column1', type: 'String' },
{ name: 'column2', type: 'String' },
],
chSql: { sql: '', params: {} },
window: {
startTime: new Date(),
endTime: new Date(),
windowIndex: 0,
direction: 'DESC' as const,
},
},
fetchNextPage: jest.fn(),
hasNextPage: false,
isLoading: false,
isFetching: false,
isError: false,
error: null,
} as any);
});
it('passes the same config to useMVOptimizationExplanation, useOffsetPaginatedQuery, and MVOptimizationIndicator', () => {
// Mock useSource to return a source so MVOptimizationIndicator is rendered
jest.mocked(useSource).mockReturnValue({
data: { id: 'test-source', name: 'Test Source' },
} as any);
renderWithMantine(<DBTableChart config={baseTestConfig} />);
// Get the config that was passed to useMVOptimizationExplanation
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
.mock.calls[0][0];
// Get the config that was passed to useOffsetPaginatedQuery
expect(jest.mocked(useOffsetPaginatedQuery)).toHaveBeenCalled();
const paginatedQueryConfig = jest.mocked(useOffsetPaginatedQuery).mock
.calls[0][0];
// Get the config that was passed to MVOptimizationIndicator
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
.calls[0][0].config;
// All three should receive the same config object reference
expect(mvOptExplanationConfig).toBe(paginatedQueryConfig);
expect(paginatedQueryConfig).toBe(indicatorConfig);
expect(mvOptExplanationConfig).toBe(indicatorConfig);
});
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
const config = {
...baseTestConfig,
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: {
...config,
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
},
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 minute',
tableName: 'metrics_rollup_1m',
},
},
],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBTableChart config={config} />);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
});
it('does not render DateRangeIndicator when MV optimization has no optimized date range', () => {
// Mock useMVOptimizationExplanation to return data without an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBTableChart config={baseTestConfig} />);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -2,15 +2,26 @@ import React from 'react';
import api from '@/api';
import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { useSource } from '@/source';
import DateRangeIndicator from '../charts/DateRangeIndicator';
import { DBTimeChart } from '../DBTimeChart';
import MVOptimizationIndicator from '../MaterializedViews/MVOptimizationIndicator';
// Mock dependencies
jest.mock('@/hooks/useChartConfig', () => ({
useQueriedChartConfig: jest.fn(),
}));
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
jest.mock('@/api', () => ({
__esModule: true,
default: {
@ -22,6 +33,12 @@ jest.mock('@/source', () => ({
useSource: jest.fn(),
}));
jest.mock('../MaterializedViews/MVOptimizationIndicator', () =>
jest.fn(() => null),
);
jest.mock('../charts/DateRangeIndicator', () => jest.fn(() => null));
describe('DBTimeChart', () => {
const mockUseQueriedChartConfig = useQueriedChartConfig as jest.Mock;
const mockUseMe = api.useMe as jest.Mock;
@ -124,4 +141,146 @@ describe('DBTimeChart', () => {
// because the enabled prop is false
expect(secondCallOptions.enabled).toBe(false);
});
it('passes the same config to useMVOptimizationExplanation, useQueriedChartConfig, and MVOptimizationIndicator', () => {
// Mock useSource to return a source so MVOptimizationIndicator is rendered
jest.mocked(useSource).mockReturnValue({
data: { id: 'test-source', name: 'Test Source' },
} as any);
renderWithMantine(<DBTimeChart config={baseTestConfig} />);
// Get the config that was passed to useMVOptimizationExplanation
expect(jest.mocked(useMVOptimizationExplanation)).toHaveBeenCalled();
const mvOptExplanationConfig = jest.mocked(useMVOptimizationExplanation)
.mock.calls[0][0];
// Get the config that was passed to useQueriedChartConfig (first call is the main query)
expect(jest.mocked(useQueriedChartConfig)).toHaveBeenCalled();
const queriedChartConfig = jest.mocked(useQueriedChartConfig).mock
.calls[0][0];
// Get the config that was passed to MVOptimizationIndicator
expect(jest.mocked(MVOptimizationIndicator)).toHaveBeenCalled();
const indicatorConfig = jest.mocked(MVOptimizationIndicator).mock
.calls[0][0].config;
// All three should receive the same config object reference
expect(mvOptExplanationConfig).toBe(queriedChartConfig);
expect(queriedChartConfig).toBe(indicatorConfig);
expect(mvOptExplanationConfig).toBe(indicatorConfig);
});
it('renders DateRangeIndicator when MV optimization returns a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T02:00:00Z');
const config = {
...baseTestConfig,
alignDateRangeToGranularity: false,
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return an optimized config with aligned date range
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: {
...config,
dateRange: [alignedStartDate, alignedEndDate] as [Date, Date],
},
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 minute',
tableName: 'metrics_rollup_1m',
},
},
],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBTimeChart config={config} />);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBe('1 minute');
});
it('renders DateRangeIndicator when alignDateRangeToGranularity is true and results in a different date range', () => {
const originalStartDate = new Date('2024-01-01T00:00:30Z');
const originalEndDate = new Date('2024-01-01T01:30:45Z');
const alignedStartDate = new Date('2024-01-01T00:00:00Z');
const alignedEndDate = new Date('2024-01-01T01:35:00Z');
const config = {
...baseTestConfig,
alignDateRangeToGranularity: true,
granularity: '5 minute',
dateRange: [originalStartDate, originalEndDate] as [Date, Date],
};
// Mock useMVOptimizationExplanation to return no optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(<DBTimeChart config={config} />);
// Verify DateRangeIndicator was called
expect(jest.mocked(DateRangeIndicator)).toHaveBeenCalled();
// Verify it was called with the correct props
const dateRangeIndicatorCall =
jest.mocked(DateRangeIndicator).mock.calls[0][0];
expect(dateRangeIndicatorCall.originalDateRange).toEqual([
originalStartDate,
originalEndDate,
]);
expect(dateRangeIndicatorCall.effectiveDateRange).toEqual([
alignedStartDate,
alignedEndDate,
]);
expect(dateRangeIndicatorCall.mvGranularity).toBeUndefined();
});
it('does not render DateRangeIndicator when MV optimization has no optimized date range and showDateRangeIndicator is false', () => {
// Mock useMVOptimizationExplanation to return data without an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: undefined,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as any);
renderWithMantine(
<DBTimeChart config={baseTestConfig} showDateRangeIndicator={false} />,
);
// Verify DateRangeIndicator was not called
expect(jest.mocked(DateRangeIndicator)).not.toHaveBeenCalled();
});
});

View file

@ -27,7 +27,7 @@ function ChartContainer({
{title}
</span>
{toolbarItems && (
<Group flex={0} wrap="nowrap">
<Group flex={0} wrap="nowrap" gap={5}>
{toolbarItems}
</Group>
)}

View file

@ -0,0 +1,42 @@
import { isDateRangeEqual } from '@hyperdx/common-utils/dist/core/utils';
import { SQLInterval } from '@hyperdx/common-utils/dist/types';
import { Tooltip } from '@mantine/core';
import { IconClock } from '@tabler/icons-react';
import { useFormatTime } from '@/useFormatTime';
interface DateRangeIndicatorProps {
originalDateRange: [Date, Date];
effectiveDateRange?: [Date, Date];
mvGranularity?: SQLInterval;
}
export default function DateRangeIndicator({
originalDateRange,
effectiveDateRange,
mvGranularity,
}: DateRangeIndicatorProps) {
const formatTime = useFormatTime();
if (
!effectiveDateRange ||
isDateRangeEqual(effectiveDateRange, originalDateRange)
) {
return null;
}
const [start, end] = [
formatTime(effectiveDateRange[0]),
formatTime(effectiveDateRange[1]),
];
const label = mvGranularity
? `Querying ${start} - ${end} due to ${mvGranularity} rollups in query acceleration.`
: `Querying ${start} - ${end} to show complete intervals.`;
return (
<Tooltip multiline maw={500} label={label}>
<IconClock size={16} color="var(--color-text)" />
</Tooltip>
);
}

View file

@ -10,12 +10,6 @@ import {
useMultipleGetKeyValues,
} from '../useMetadata';
if (!globalThis.structuredClone) {
globalThis.structuredClone = (obj: any) => {
return JSON.parse(JSON.stringify(obj));
};
}
// Mock dependencies
jest.mock('../useMetadata', () => ({
...jest.requireActual('../useMetadata.tsx'),

View file

@ -15,6 +15,7 @@ import {
getGranularityAlignedTimeWindows,
useQueriedChartConfig,
} from '../useChartConfig';
import { useMVOptimizationExplanation } from '../useMVOptimizationExplanation';
// Mock the clickhouse module
jest.mock('@/clickhouse', () => ({
@ -34,6 +35,14 @@ jest.mock('@/config', () => ({
IS_MTVIEWS_ENABLED: false,
}));
// Mock the MV optimization module
jest.mock('../useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
}),
}));
// Create a mock ChartConfig
const createMockChartConfig = (
overrides: Partial<ChartConfigWithOptDateRange> = {},
@ -1291,5 +1300,107 @@ describe('useChartConfig', () => {
isComplete: true,
});
});
it('should not execute query while useMVOptimizationExplanation is in loading state', async () => {
const config = createMockChartConfig({
dateRange: [
new Date('2025-10-01 00:00:00Z'),
new Date('2025-10-02 00:00:00Z'),
],
granularity: '1 hour',
from: {
databaseName: 'default',
tableName: 'otel_logs',
},
select: [
{
aggCondition: '',
aggFn: 'count',
valueExpression: '',
},
],
});
const optimizedConfig = createMockChartConfig({
...config,
from: {
databaseName: 'default',
tableName: 'metrics_rollup_1h',
},
select: [
{
aggCondition: '',
aggFn: 'countMerge',
valueExpression: 'count__',
},
],
});
// Mock useMVOptimizationExplanation to be in loading state
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: undefined,
isLoading: true, // MV optimization is still loading
} as any);
renderHook(
() => useQueriedChartConfig(config, { enableQueryChunking: true }),
{
wrapper,
},
);
// Wait a bit to ensure query doesn't execute
await new Promise(resolve => setTimeout(resolve, 100));
// Verify the query hasn't started because MV optimization is loading
expect(mockClickhouseClient.queryChartConfig).not.toHaveBeenCalled();
// Now mock the MV optimization to finish loading with an optimized config
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: optimizedConfig,
explanations: [
{
success: true,
mvConfig: {
minGranularity: '1 hour',
tableName: 'metrics_rollup_1h',
},
},
],
},
isLoading: false, // MV optimization finished loading
} as any);
const mockResponse = createMockQueryResponse([
{
'count()': '71',
SeverityText: 'info',
__hdx_time_bucket: '2025-10-01T00:00:00Z',
},
]);
mockClickhouseClient.queryChartConfig.mockResolvedValue(mockResponse);
// Re-render with MV optimization completed
const { result: result2 } = renderHook(
() => useQueriedChartConfig(config, { enableQueryChunking: true }),
{
wrapper,
},
);
await waitFor(() => expect(result2.current.isSuccess).toBe(true));
await waitFor(() => expect(result2.current.isFetching).toBe(false));
// Verify the query was executed after MV optimization finished
expect(mockClickhouseClient.queryChartConfig).toHaveBeenCalled();
// Verify the query used the optimized config (materialized view)
const queryCall = mockClickhouseClient.queryChartConfig.mock.calls[0][0];
expect(queryCall.config.from.tableName).toBe('metrics_rollup_1h');
expect(result2.current.data?.data).toBeDefined();
});
});
});

View file

@ -1,7 +1,11 @@
import React, { act } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
QueryClient,
QueryClientProvider,
UseQueryResult,
} from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import useOffsetPaginatedQuery from '../useOffsetPaginatedQuery';
@ -27,6 +31,15 @@ jest.mock('@hyperdx/app/src/metadata', () => ({
getMetadata: jest.fn(),
}));
// Mock the useMVOptimizationExplanation hook
jest.mock('@/hooks/useMVOptimizationExplanation', () => ({
useMVOptimizationExplanation: jest.fn().mockReturnValue({
data: undefined,
isLoading: false,
isPlaceholderData: false,
}),
}));
// Mock the renderChartConfig function
jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({
renderChartConfig: jest.fn(),
@ -34,8 +47,14 @@ jest.mock('@hyperdx/common-utils/dist/core/renderChartConfig', () => ({
// Import mocked modules after jest.mock calls
import { getClickhouseClient } from '@hyperdx/app/src/clickhouse';
import { MVOptimizationExplanation } from '@hyperdx/common-utils/dist/core/materializedViews';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
MVOptimizationExplanationResult,
useMVOptimizationExplanation,
} from '@/hooks/useMVOptimizationExplanation';
// Create a mock ChartConfig based on the Zod schema
const createMockChartConfig = (
overrides: Partial<ChartConfigWithDateRange> = {},
@ -743,4 +762,168 @@ describe('useOffsetPaginatedQuery', () => {
expect(result2.current.data?.data[0].message).toBe('config2 log');
});
});
describe('MV Optimization Integration', () => {
it('should optimize queries using MVs when possible', async () => {
const config = createMockChartConfig({
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: 'Duration',
aggFn: 'avg',
},
],
});
const optimizedConfig = createMockChartConfig({
from: {
databaseName: 'default',
tableName: 'metrics_rollup_1m',
},
select: [
{
valueExpression: 'avg__Duration',
aggFn: 'avgMerge',
},
],
});
// Mock MV optimization to return an optimized config
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig,
explanations: [],
},
isLoading: false,
isPlaceholderData: false,
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
// Mock the reader to return data
mockReader.read
.mockResolvedValueOnce({
done: false,
value: [
{ json: () => ['timestamp', 'avg_duration'] },
{ json: () => ['DateTime', 'Float64'] },
{ json: () => ['2024-01-01T01:00:00Z', 123.45] },
],
})
.mockResolvedValueOnce({ done: true });
const { result } = renderHook(() => useOffsetPaginatedQuery(config), {
wrapper,
});
await waitFor(() => expect(result.current.isLoading).toBe(false));
// Verify the query was executed with the optimized config
expect(renderChartConfig).toHaveBeenCalledTimes(1);
expect(
jest
.mocked(renderChartConfig)
.mock.calls.every(
call => call[0].from.tableName === 'metrics_rollup_1m',
),
).toBeTruthy();
// Verify data was returned successfully
expect(result.current.data?.data).toHaveLength(1);
});
it('should not execute query while useMVOptimizationExplanation is in loading state', async () => {
const config = createMockChartConfig({
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: 'Duration',
aggFn: 'avg',
},
],
});
const optimizedConfig = createMockChartConfig({
from: {
databaseName: 'default',
tableName: 'metrics_rollup_1m',
},
select: [
{
valueExpression: 'avg__Duration',
aggFn: 'avgMerge',
},
],
});
// Mock MV optimization to be in loading state
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: undefined,
isLoading: true, // MV optimization is still loading
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
const { result } = renderHook(() => useOffsetPaginatedQuery(config), {
wrapper,
});
// Wait a bit to ensure query doesn't execute
await new Promise(resolve => setTimeout(resolve, 100));
// Verify the query is still in loading state because MV optimization is loading
expect(result.current.isLoading).toBe(true);
// Verify that renderChartConfig was NOT called because MV optimization is still loading
expect(renderChartConfig).not.toHaveBeenCalled();
// Now mock the MV optimization to finish loading
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
jest.mocked(useMVOptimizationExplanation).mockReturnValue({
data: {
optimizedConfig: optimizedConfig,
explanations: [],
},
isLoading: false, // MV optimization finished loading
} as unknown as UseQueryResult<MVOptimizationExplanationResult>);
// Mock the reader to return data
mockReader.read
.mockResolvedValueOnce({
done: false,
value: [
{ json: () => ['timestamp', 'avg_duration'] },
{ json: () => ['DateTime', 'Float64'] },
{ json: () => ['2024-01-01T01:00:00Z', 123.45] },
],
})
.mockResolvedValueOnce({ done: true });
// Force a re-render to pick up the new mock value
const { result: result2 } = renderHook(
() => useOffsetPaginatedQuery(config),
{
wrapper,
},
);
await waitFor(() => expect(result2.current.isLoading).toBe(false));
// Verify the query was executed with the optimized config after MV optimization finished
expect(renderChartConfig).toHaveBeenCalledTimes(1);
expect(
jest
.mocked(renderChartConfig)
.mock.calls.every(
call => call[0].from.tableName === 'metrics_rollup_1m',
),
).toBeTruthy();
expect(result2.current.data?.data).toHaveLength(1);
});
});
});

View file

@ -36,6 +36,8 @@ import { getMetadata } from '@/metadata';
import { useSource } from '@/source';
import { generateTimeWindowsDescending } from '@/utils/searchWindows';
import { useMVOptimizationExplanation } from './useMVOptimizationExplanation';
interface AdditionalUseQueriedChartConfigOptions {
onError?: (error: Error | ClickHouseQueryError) => void;
/**
@ -259,9 +261,11 @@ export function useQueriedChartConfig(
const queryClient = useQueryClient();
const metadata = useMetadataWithSettings();
const { data: source, isLoading: isLoadingSource } = useSource({
id: config.source,
});
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
useMVOptimizationExplanation(config, {
enabled: !!enabled,
placeholderData: undefined,
});
const query = useQuery<TQueryFnData, ClickHouseQueryError | Error>({
// Include enableQueryChunking in the query key to ensure that queries with the
@ -274,16 +278,7 @@ export function useQueriedChartConfig(
// TODO: Replace this with `streamedQuery` when it is no longer experimental. Use 'replace' refetch mode.
// https://tanstack.com/query/latest/docs/reference/streamedQuery
queryFn: async context => {
const optimizedConfig = source?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(
config,
metadata,
clickhouseClient,
context.signal,
source,
)
: config;
const optimizedConfig = mvOptimizationData?.optimizedConfig ?? config;
const query = queryClient
.getQueryCache()
.find({ queryKey: context.queryKey, exact: true });
@ -334,7 +329,7 @@ export function useQueriedChartConfig(
retry: 1,
refetchOnWindowFocus: false,
...options,
enabled: enabled && !isLoadingSource,
enabled: enabled && !isLoadingMVOptimization,
});
if (query.isError && options?.onError) {
@ -342,7 +337,7 @@ export function useQueriedChartConfig(
}
return {
...query,
isLoading: query.isLoading || isLoadingSource,
isLoading: query.isLoading || isLoadingMVOptimization,
};
}
@ -351,36 +346,27 @@ export function useRenderedSqlChartConfig(
options?: UseQueryOptions<string>,
) {
const { enabled = true } = options ?? {};
const metadata = useMetadataWithSettings();
const clickhouseClient = useClickhouseClient();
const { data: source, isLoading: isLoadingSource } = useSource({
id: config.source,
});
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
useMVOptimizationExplanation(config, {
enabled: !!enabled,
placeholderData: undefined,
});
const query = useQuery({
queryKey: ['renderedSql', config],
queryFn: async ({ signal }) => {
const optimizedConfig = source?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(
config,
metadata,
clickhouseClient,
signal,
source,
)
: config;
queryFn: async () => {
const optimizedConfig = mvOptimizationData?.optimizedConfig ?? config;
const query = await renderChartConfig(optimizedConfig, getMetadata());
return format(parameterizedQueryToSql(query));
},
...options,
enabled: enabled && !isLoadingSource,
enabled: enabled && !isLoadingMVOptimization,
});
return {
...query,
isLoading: query.isLoading || isLoadingSource,
isLoading: query.isLoading || isLoadingMVOptimization,
};
}

View file

@ -14,14 +14,18 @@ import { useSource } from '@/source';
import { useMetadataWithSettings } from './useMetadata';
export interface MVOptimizationExplanationResult {
optimizedConfig?: ChartConfigWithOptDateRange;
export interface MVOptimizationExplanationResult<
C extends ChartConfigWithOptDateRange = ChartConfigWithOptDateRange,
> {
optimizedConfig?: C;
explanations: MVOptimizationExplanation[];
}
export function useMVOptimizationExplanation(
config: ChartConfigWithOptDateRange | undefined,
options?: UseQueryOptions<MVOptimizationExplanationResult>,
export function useMVOptimizationExplanation<
C extends ChartConfigWithOptDateRange,
>(
config: C | undefined,
options?: Partial<UseQueryOptions<MVOptimizationExplanationResult<C>>>,
) {
const { enabled = true } = options || {};
const metadata = useMetadataWithSettings();
@ -31,7 +35,7 @@ export function useMVOptimizationExplanation(
id: config?.source,
});
return useQuery<MVOptimizationExplanationResult>({
return useQuery<MVOptimizationExplanationResult<C>>({
queryKey: ['optimizationExplanation', config],
queryFn: async ({ signal }) => {
if (!config || !source) {
@ -49,6 +53,7 @@ export function useMVOptimizationExplanation(
);
},
placeholderData: keepPreviousData,
staleTime: 5000,
...options,
enabled: enabled && !isLoadingSource && !!config && !!source,
});

View file

@ -6,17 +6,13 @@ import {
ClickHouseQueryError,
ColumnMetaType,
} from '@hyperdx/common-utils/dist/clickhouse';
import { tryOptimizeConfigWithMaterializedView } from '@hyperdx/common-utils/dist/core/materializedViews';
import { Metadata } from '@hyperdx/common-utils/dist/core/metadata';
import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartConfig';
import {
isFirstOrderByAscending,
isTimestampExpressionInFirstOrderBy,
} from '@hyperdx/common-utils/dist/core/utils';
import {
ChartConfigWithOptTimestamp,
TSource,
} from '@hyperdx/common-utils/dist/types';
import { ChartConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
import {
QueryClient,
QueryFunction,
@ -27,7 +23,7 @@ import {
import api from '@/api';
import { getClickhouseClient } from '@/clickhouse';
import { useMetadataWithSettings } from '@/hooks/useMetadata';
import { useSource } from '@/source';
import { useMVOptimizationExplanation } from '@/hooks/useMVOptimizationExplanation';
import { omit } from '@/utils';
import {
generateTimeWindowsAscending,
@ -70,7 +66,7 @@ type QueryMeta = {
queryClient: QueryClient;
hasPreviousQueries: boolean;
metadata: Metadata;
source?: TSource;
optimizedConfig?: ChartConfigWithOptTimestamp;
};
// Get time window from page param
@ -145,7 +141,7 @@ const queryFn: QueryFunction<TQueryFnData, TQueryKey, TPageParam> = async ({
throw new Error('Query missing client meta');
}
const { queryClient, metadata, hasPreviousQueries, source } =
const { queryClient, metadata, hasPreviousQueries, optimizedConfig } =
meta as QueryMeta;
// Only stream incrementally if this is a fresh query with no previous
@ -158,15 +154,7 @@ const queryFn: QueryFunction<TQueryFnData, TQueryKey, TPageParam> = async ({
const clickhouseClient = getClickhouseClient({ queryTimeout });
const rawConfig = queryKey[1];
const config = source?.materializedViews?.length
? await tryOptimizeConfigWithMaterializedView(
rawConfig,
metadata,
clickhouseClient,
signal,
source,
)
: rawConfig;
const config = optimizedConfig ?? rawConfig;
// Get the time window for this page
const shouldUseWindowing = isTimestampExpressionInFirstOrderBy(config);
@ -405,9 +393,11 @@ export default function useOffsetPaginatedQuery(
const hasPreviousQueries =
matchedQueries.filter(([_, data]) => data != null).length > 0;
const { data: source, isLoading: isLoadingSource } = useSource({
id: config.source,
});
const { data: mvOptimizationData, isLoading: isLoadingMVOptimization } =
useMVOptimizationExplanation(config, {
enabled: !!enabled,
placeholderData: undefined,
});
const {
data,
@ -429,7 +419,7 @@ export default function useOffsetPaginatedQuery(
// Only preserve previous query in live mode
return isLive ? prev : undefined;
},
enabled: enabled && !isLoadingMe && !isLoadingSource,
enabled: enabled && !isLoadingMe && !isLoadingMVOptimization,
initialPageParam: { windowIndex: 0, offset: 0 } as TPageParam,
getNextPageParam: (lastPage, allPages) => {
return getNextPageParam(lastPage, allPages, config);
@ -439,7 +429,7 @@ export default function useOffsetPaginatedQuery(
queryClient,
hasPreviousQueries,
metadata,
source,
optimizedConfig: mvOptimizationData?.optimizedConfig,
} satisfies QueryMeta,
queryFn,
gcTime: isLive ? ms('30s') : ms('5m'), // more aggressive gc for live data, since it can end up holding lots of data
@ -456,7 +446,7 @@ export default function useOffsetPaginatedQuery(
data: flattenedData,
fetchNextPage,
hasNextPage,
isFetching: isFetching || isLoadingMe || isLoadingSource,
isLoading: isLoading || isLoadingMe || isLoadingSource,
isFetching: isFetching || isLoadingMe || isLoadingMVOptimization,
isLoading: isLoading || isLoadingMe || isLoadingMVOptimization,
};
}

View file

@ -4,6 +4,7 @@ import { TextDecoder, TextEncoder } from 'util';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { render } from '@testing-library/react';
import structuredClone from '@ungap/structured-clone';
import '@testing-library/jest-dom';
global.TextEncoder = TextEncoder as any;
@ -35,6 +36,11 @@ global.renderWithMantine = (ui: React.ReactElement) => {
);
};
if (!globalThis.structuredClone) {
// @ts-expect-error this is a correct polyfill
globalThis.structuredClone = structuredClone;
}
declare global {
function renderWithMantine(ui: React.ReactElement): ReturnType<typeof render>;
}

View file

@ -12,6 +12,7 @@ import {
convertToDashboardTemplate,
findJsonExpressions,
formatDate,
getAlignedDateRange,
getFirstOrderingItem,
isFirstOrderByAscending,
isJsonExpression,
@ -1315,4 +1316,111 @@ describe('utils', () => {
},
);
});
describe('getAlignedDateRange', () => {
it('should align start time down to the previous minute boundary', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:37Z'), // 37 seconds
new Date('2025-11-26T12:25:00Z'),
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
});
it('should align end time up to the next minute boundary', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:00Z'),
new Date('2025-11-26T12:25:42Z'), // 42 seconds
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
});
it('should align both start and end times with 5 minute granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:20:00
new Date('2025-11-26T12:27:42Z'), // Should round up to 12:30:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'5 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:20:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:30:00.000Z');
});
it('should align with 30 second granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:23:00
new Date('2025-11-26T12:25:42Z'), // Should round up to 12:26:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'30 second',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:26:00.000Z');
});
it('should align with 1 day granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to start of day
new Date('2025-11-28T08:15:00Z'), // Should round up to start of next day
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 day',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T00:00:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-29T00:00:00.000Z');
});
it('should not change range when already aligned to the interval', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:00Z'), // Already aligned
new Date('2025-11-26T12:25:00Z'), // Already aligned
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'1 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:23:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T12:25:00.000Z');
});
it('should align with 15 minute granularity', () => {
const dateRange: [Date, Date] = [
new Date('2025-11-26T12:23:17Z'), // Should round down to 12:15:00
new Date('2025-11-26T12:47:42Z'), // Should round up to 13:00:00
];
const [alignedStart, alignedEnd] = getAlignedDateRange(
dateRange,
'15 minute',
);
expect(alignedStart.toISOString()).toBe('2025-11-26T12:15:00.000Z');
expect(alignedEnd.toISOString()).toBe('2025-11-26T13:00:00.000Z');
});
});
});

View file

@ -895,7 +895,7 @@ describe('materializedViews', () => {
connection: 'test-connection',
dateRange: [
new Date('2023-12-01T00:00:00Z'),
new Date('2023-12-31T23:59:59Z'),
new Date('2024-01-01T00:00:00Z'),
],
};
@ -915,7 +915,7 @@ describe('materializedViews', () => {
},
dateRange: [
new Date('2023-12-01T00:00:00Z'),
new Date('2023-12-31T23:59:59Z'),
new Date('2024-01-01T00:00:00Z'),
],
select: [
{
@ -976,6 +976,53 @@ describe('materializedViews', () => {
});
expect(result.errors).toBeUndefined();
});
it('should align dateRange to MV granularity when optimizing a config with dateRange', async () => {
const chartConfig: ChartConfigWithOptDateRange = {
from: {
databaseName: 'default',
tableName: 'otel_spans',
},
select: [
{
valueExpression: '',
aggFn: 'count',
},
],
where: '',
connection: 'test-connection',
dateRange: [
new Date('2023-01-01T00:00:30Z'),
new Date('2023-01-02T01:00:45Z'),
],
};
const result = await tryConvertConfigToMaterializedViewSelect(
chartConfig,
MV_CONFIG_METRIC_ROLLUP_1M,
metadata,
);
expect(result.optimizedConfig).toEqual({
from: {
databaseName: 'default',
tableName: 'metrics_rollup_1m',
},
select: [
{
valueExpression: 'count',
aggFn: 'sum',
},
],
where: '',
connection: 'test-connection',
dateRange: [
new Date('2023-01-01T00:00:00Z'),
new Date('2023-01-02T01:01:00Z'),
],
dateRangeEndInclusive: false,
});
});
});
describe('isUnsupportedCountFunction', () => {

View file

@ -13,6 +13,7 @@ import { DEFAULT_AUTO_GRANULARITY_MAX_BUCKETS } from './renderChartConfig';
import {
convertDateRangeToGranularityString,
convertGranularityToSeconds,
getAlignedDateRange,
} from './utils';
type SelectItem = Exclude<
@ -348,7 +349,16 @@ export async function tryConvertConfigToMaterializedViewSelect<
tableName: mvConfig.tableName,
},
// Make the date range end exclusive to avoid selecting the entire next time bucket from the MV
...('dateRange' in chartConfig ? { dateRangeEndInclusive: false } : {}),
// Align the date range to the MV granularity to avoid excluding the first time bucket
...('dateRange' in chartConfig && chartConfig.dateRange
? {
dateRangeEndInclusive: false,
dateRange: getAlignedDateRange(
chartConfig.dateRange,
mvConfig.minGranularity,
),
}
: {}),
};
return {

View file

@ -662,3 +662,27 @@ export function optimizeTimestampValueExpression(
return timestampValueExprs.join(', ');
}
export function getAlignedDateRange(
[originalStart, originalEnd]: [Date, Date],
granularity: SQLInterval,
): [Date, Date] {
// Round the start time down to the previous interval boundary
const alignedStart = toStartOfInterval(originalStart, granularity);
// Round the end time up to the next interval boundary
let alignedEnd = toStartOfInterval(originalEnd, granularity);
if (alignedEnd.getTime() < originalEnd.getTime()) {
const intervalSeconds = convertGranularityToSeconds(granularity);
alignedEnd = fnsAdd(alignedEnd, { seconds: intervalSeconds });
}
return [alignedStart, alignedEnd];
}
export function isDateRangeEqual(range1: [Date, Date], range2: [Date, Date]) {
return (
range1[0].getTime() === range2[0].getTime() &&
range1[1].getTime() === range2[1].getTime()
);
}

View file

@ -9701,6 +9701,13 @@ __metadata:
languageName: node
linkType: hard
"@types/ungap__structured-clone@npm:^1.2.0":
version: 1.2.0
resolution: "@types/ungap__structured-clone@npm:1.2.0"
checksum: 10c0/52f341ded16708603e5631769b26acf0e9ed7c556ce81abb4e55962110b30442576c631c8f7e298b561b8ff77c7823f0edc9f5e313e1c5e1441825a590e5b0f3
languageName: node
linkType: hard
"@types/unist@npm:*, @types/unist@npm:^3.0.0":
version: 3.0.2
resolution: "@types/unist@npm:3.0.2"
@ -16825,8 +16832,10 @@ __metadata:
"@changesets/cli": "npm:^2.26.2"
"@dotenvx/dotenvx": "npm:^1.51.1"
"@nx/workspace": "npm:21.3.11"
"@types/ungap__structured-clone": "npm:^1.2.0"
"@typescript-eslint/eslint-plugin": "npm:^8.48.1"
"@typescript-eslint/parser": "npm:^8.48.1"
"@ungap/structured-clone": "npm:^1.3.0"
babel-plugin-react-compiler: "npm:^1.0.0"
concurrently: "npm:^9.1.2"
dotenv: "npm:^16.4.7"