This is an automated email from the ASF dual-hosted git repository.
aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new be40309811d fix: filter Sunburst temporal segments by bucket and
invert Hours offset in grained filters (#45598)
be40309811d is described below
commit be40309811d8836ff50c4f3f5f713553b6924d51
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 22 19:07:19 2026 -0400
fix: filter Sunburst temporal segments by bucket and invert Hours offset in
grained filters (#45598)
---
docs/docs/using-superset/exploring-data.mdx | 21 +
.../src/Sunburst/EchartsSunburst.tsx | 99 +++--
.../src/Sunburst/transformProps.ts | 36 +-
.../plugin-chart-echarts/src/Sunburst/types.ts | 4 +
.../plugin-chart-echarts/src/Sunburst/utils.ts | 190 ++++++++
.../src/utils/xAxisDrillByFilter.ts | 37 +-
.../test/Sunburst/EchartsSunburst.test.tsx | 474 ++++++++++++++++++++
.../test/Sunburst/transformProps.test.ts | 149 ++++++-
.../test/Sunburst/utils.test.ts | 52 +++
.../test/Timeseries/EchartsTimeseries.test.tsx | 40 ++
.../Chart/DrillBy/DrillByTemporalFilters.test.tsx | 256 +++++++++++
superset/models/helpers.py | 91 +++-
.../models/test_hours_offset_bound_truncation.py | 480 ++++++++++++++++++++-
13 files changed, 1860 insertions(+), 69 deletions(-)
diff --git a/docs/docs/using-superset/exploring-data.mdx
b/docs/docs/using-superset/exploring-data.mdx
index 90a59d87c1a..d538b67e5ac 100644
--- a/docs/docs/using-superset/exploring-data.mdx
+++ b/docs/docs/using-superset/exploring-data.mdx
@@ -489,6 +489,27 @@ The **Custom** time range picker accepts natural language
expressions alongside
These expressions are evaluated at query time, so saved charts always display
data relative to the current date.
+### Sunburst cross-filtering
+
+Sunburst cross-filters use the underlying hierarchy values, not the formatted
+segment labels. For time-grained temporal levels, selecting a segment filters
the
+entire bucket. Adaptive date formatting includes the year in month and quarter
+labels to distinguish buckets across years; other grains keep their compact
labels.
+For supported bucket ranges, the selection replaces the target chart's Time
Range
+and also applies to its other temporal range filters, even on another time
column.
+Drill to detail matches the selected bucket, including a dataset Hours offset,
unless
+the chart also has a Time Range filter on the grained Time Column. Drill by
matches the
+bucket only when the chart has no Time Range filter. Otherwise the query
rebuilds its
+time bounds from the chart or dashboard Time Range, so the drill returns every
row in
+that range. An engine-specific grain without a fixed length matches only the
start of
+the bucket.
+
+On engines without SQL temporal shifts, grained comparisons (Drill to detail,
grained
+`IN` / `NOT IN` cross-filters and other bucket comparisons) use the displayed
bucket's
+original, unshifted grouping boundaries, undoing the display-only Hours offset
for each
+selected value. A temporal level without a time grain is compared with the
stored
+timestamp, so with an Hours offset its Drill to detail does not match the
displayed value.
+
### Downloading Drill to Detail and Drill By Results
The **Drill to detail** and **Drill by** modals, available from a chart's
context menu, show the row-level
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
index 8ba09fd5d8f..7420e32ebba 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
@@ -20,13 +20,17 @@ import { useCallback } from 'react';
import {
BinaryQueryObjectFilterClause,
getColumnLabel,
- getNumberFormatter,
- getTimeFormatter,
+ DataRecordValue,
} from '@superset-ui/core';
import { SunburstTransformedProps } from './types';
import Echart from '../components/Echart';
import { EventHandlers, TreePathInfo } from '../types';
-import { formatSeriesName } from '../utils/series';
+import {
+ getSunburstDetailFilter,
+ getSunburstDrillByFilter,
+ getSunburstFilter,
+ getSunburstSelectionKey,
+} from './utils';
export const extractTreePathInfo = (treePathInfo: TreePathInfo[] | undefined)
=>
(treePathInfo ?? [])
@@ -46,18 +50,25 @@ export default function EchartsSunburst(props:
SunburstTransformedProps) {
emitCrossFilters,
coltypeMapping,
} = props;
- const { columns } = formData;
+ const { columns = [] } = formData;
const getCrossFilterDataMask = useCallback(
- (treePathInfo: TreePathInfo[]) => {
+ (treePathInfo: TreePathInfo[], records: DataRecordValue[]) => {
const treePath = extractTreePathInfo(treePathInfo);
- const joinedTreePath = treePath.join(',');
+ const joinedTreePath = getSunburstSelectionKey(
+ records,
+ treePath,
+ columns,
+ coltypeMapping,
+ );
+ const index = records.length - 1;
const value = treePath[treePath.length - 1];
const isCurrentValueSelected =
- Object.values(selectedValues).includes(joinedTreePath);
+ Object.values(selectedValues).includes(joinedTreePath) ||
+ Object.values(selectedValues).includes(treePath.join(','));
- if (!columns?.length || isCurrentValueSelected) {
+ if (!columns?.length || !records.length || isCurrentValueSelected) {
return {
dataMask: {
extraFormData: {
@@ -72,16 +83,21 @@ export default function EchartsSunburst(props:
SunburstTransformedProps) {
};
}
+ const filter = getSunburstFilter(
+ columns[index],
+ records[index],
+ formData,
+ coltypeMapping?.[getColumnLabel(columns[index])],
+ );
+
return {
dataMask: {
extraFormData: {
- filters: [
- {
- col: columns[treePath.length - 1],
- op: '==' as const,
- val: value,
- },
- ],
+ filters: [filter],
+ // Target queries rewrite temporal filter bounds from time_range.
+ ...(filter.op === 'TEMPORAL_RANGE' && {
+ time_range: String(filter.val),
+ }),
},
filterState: {
value,
@@ -91,24 +107,24 @@ export default function EchartsSunburst(props:
SunburstTransformedProps) {
isCurrentValueSelected,
};
},
- [columns, selectedValues],
+ [columns, selectedValues, coltypeMapping, formData],
);
const handleChange = useCallback(
- (treePathInfo: TreePathInfo[]) => {
+ (treePathInfo: TreePathInfo[], records: DataRecordValue[]) => {
if (!emitCrossFilters || !columns?.length) {
return;
}
- setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
+ setDataMask(getCrossFilterDataMask(treePathInfo, records).dataMask);
},
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {
click: props => {
- const { treePathInfo } = props;
- handleChange(treePathInfo);
+ const { treePathInfo, data } = props;
+ handleChange(treePathInfo, data.records);
},
contextmenu: async eventParams => {
if (onContextMenu) {
@@ -120,31 +136,34 @@ export default function EchartsSunburst(props:
SunburstTransformedProps) {
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
if (columns?.length) {
- treePath.forEach((path, i) =>
- drillToDetailFilters.push({
- col: columns[i],
- op: '==',
- val: records[i],
- formattedVal: path,
- }),
- );
- const val = treePath[treePath.length - 1];
- drillByFilters.push({
- col: columns[treePath.length - 1],
- op: '==',
- val,
- formattedVal: formatSeriesName(val, {
- timeFormatter: getTimeFormatter(formData.dateFormat),
- numberFormatter: getNumberFormatter(formData.numberFormat),
- coltype:
- coltypeMapping?.[getColumnLabel(columns[treePath.length - 1])],
- }),
+ records.forEach((record: DataRecordValue, i: number) => {
+ const coltype = coltypeMapping?.[getColumnLabel(columns[i])];
+ drillToDetailFilters.push(
+ getSunburstDetailFilter(
+ columns[i],
+ record,
+ formData,
+ coltype,
+ treePath[i],
+ ),
+ );
+ if (i === records.length - 1) {
+ drillByFilters.push(
+ getSunburstDrillByFilter(
+ columns[i],
+ record,
+ formData,
+ coltype,
+ treePath[i],
+ ),
+ );
+ }
});
}
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
drillToDetail: drillToDetailFilters,
crossFilter: columns?.length
- ? getCrossFilterDataMask(treePathInfo)
+ ? getCrossFilterDataMask(treePathInfo, records)
: undefined,
drillBy: { filters: drillByFilters, groupbyFieldName: 'columns' },
});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
index f04d67857cc..90c93c3c45b 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
@@ -25,6 +25,9 @@ import {
getNumberFormatter,
getSequentialSchemeRegistry,
getTimeFormatter,
+ getTimeFormatterForGranularity,
+ SMART_DATE_ID,
+ TimeGranularity,
getValueFormatter,
NumberFormats,
tooltipHtml,
@@ -44,6 +47,11 @@ import {
SunburstTransformedProps,
} from './types';
import { getDefaultTooltip } from '../utils/tooltip';
+import {
+ getSunburstTimeGrain,
+ getSunburstSelectionKey,
+ isSunburstPathSelected,
+} from './utils';
export function getLinearDomain(
treeData: TreeNode[],
@@ -298,9 +306,22 @@ export default function transformProps(
treeNodes.map(treeNode => {
const { name: nodeName, value, secondaryValue, groupBy } = treeNode;
const records = [...(pathRecords || []), nodeName];
+ const timeGrain = getSunburstTimeGrain(
+ formData,
+ columns[records.length - 1],
+ );
let name = formatSeriesName(nodeName, {
numberFormatter,
- timeFormatter: getTimeFormatter(dateFormat),
+ timeFormatter:
+ dateFormat === SMART_DATE_ID &&
+ timeGrain &&
+ [
+ TimeGranularity.MONTH,
+ TimeGranularity.QUARTER,
+ TimeGranularity.YEAR,
+ ].some(grain => grain === timeGrain)
+ ? getTimeFormatterForGranularity(timeGrain)
+ : getTimeFormatter(dateFormat),
...(coltypeMapping[groupBy] && {
coltype: coltypeMapping[groupBy],
}),
@@ -323,7 +344,18 @@ export default function transformProps(
name = newPath.join(',');
}
columnsLabelMap.set(name, newPath);
- if (filterState.selectedValues?.[0]?.includes(name) === false) {
+ const selectionKey = getSunburstSelectionKey(
+ records,
+ newPath,
+ columns,
+ coltypeMapping,
+ );
+ const selectedValue = filterState.selectedValues?.[0];
+ if (
+ selectedValue != null &&
+ !isSunburstPathSelected(selectedValue, selectionKey) &&
+ !isSunburstPathSelected(selectedValue, newPath.join(','))
+ ) {
item = {
...item,
itemStyle: {
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
index ef278abd6e2..c3bcde3f10a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
@@ -24,6 +24,7 @@ import {
QueryFormColumn,
QueryFormData,
QueryFormMetric,
+ TimeGranularity,
} from '@superset-ui/core';
import type { SunburstSeriesNodeItemOption } from
'echarts/types/src/chart/sunburst/SunburstSeries';
import {
@@ -38,6 +39,9 @@ export type EchartsSunburstFormData = QueryFormData & {
secondaryMetric?: QueryFormMetric;
colorScheme?: string;
linearColorScheme?: string;
+ timeGrainSqla?: TimeGranularity;
+ temporalColumnsLookup?: Record<string, boolean>;
+ granularitySqla?: string;
};
export enum EchartsSunburstLabelType {
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts
new file mode 100644
index 00000000000..923f5052269
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts
@@ -0,0 +1,190 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ BinaryQueryObjectFilterClause,
+ DataRecordValue,
+ getColumnLabel,
+ isPhysicalColumn,
+ QueryFormColumn,
+ TimeGranularity,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { EchartsSunburstFormData } from './types';
+import {
+ formatNaiveDateTime,
+ getTemporalXAxisDrillByFilter,
+ parseTemporalValue,
+} from '../utils/xAxisDrillByFilter';
+
+/**
+ * Return the effective grain only for a grained physical hierarchy level. A
+ * dashboard Time Column filter supplies the grained column through
+ * ``extraFormData.granularity_sqla``, which the query uses as its granularity.
+ */
+export function getSunburstTimeGrain(
+ formData: EchartsSunburstFormData,
+ column: QueryFormColumn,
+): TimeGranularity | undefined {
+ if (!isPhysicalColumn(column)) {
+ return undefined;
+ }
+ const effectiveColumn =
+ formData.extraFormData?.granularity_sqla ?? formData.granularitySqla;
+ const temporalLevels = (formData.columns ?? []).filter(
+ level => isPhysicalColumn(level) &&
formData.temporalColumnsLookup?.[level],
+ );
+ const grainedColumn =
+ effectiveColumn ||
+ (temporalLevels.length === 1 ? temporalLevels[0] : undefined);
+ return grainedColumn === column
+ ? (formData.extraFormData?.time_grain_sqla ?? formData.timeGrainSqla)
+ : undefined;
+}
+
+/**
+ * Whether a segment is the selected segment or one of its ancestors. Keys are
+ * compared level by level, so a year label never matches digits inside an
+ * epoch key.
+ */
+export function isSunburstPathSelected(
+ selectedValue: unknown,
+ key: string,
+): boolean {
+ const selected = String(selectedValue);
+ return selected === key || selected.startsWith(`${key},`);
+}
+
+/** Keep temporal selection identity independent of the display format. */
+export function getSunburstSelectionKey(
+ records: DataRecordValue[],
+ labels: string[],
+ columns: QueryFormColumn[],
+ coltypeMapping: Record<string, GenericDataType> = {},
+): string {
+ return records
+ .map((value, index) =>
+ coltypeMapping[getColumnLabel(columns[index])] ===
+ GenericDataType.Temporal
+ ? String(value instanceof Date ? value.getTime() : value)
+ : labels[index],
+ )
+ .join(',');
+}
+
+/**
+ * Exact temporal value for filters the backend receives as written. A physical
+ * column gets epoch milliseconds, which the backend turns into an
engine-native
+ * timestamp literal. An adhoc SQL expression has no native type to convert to,
+ * so it gets the naive ISO datetime instead of an integer.
+ */
+function getSunburstTemporalValue(
+ column: QueryFormColumn,
+ value: DataRecordValue,
+): DataRecordValue {
+ const date = parseTemporalValue(value);
+ if (!date) {
+ return value;
+ }
+ return isPhysicalColumn(column) ? date.getTime() : formatNaiveDateTime(date);
+}
+
+/** Build cross-filters from raw hierarchy records, preserving bucket bounds.
*/
+export function getSunburstFilter(
+ column: QueryFormColumn,
+ value: DataRecordValue,
+ formData: EchartsSunburstFormData,
+ coltype: GenericDataType | undefined,
+ formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+ if (coltype !== GenericDataType.Temporal) {
+ return { col: column, op: '==', val: value, formattedVal };
+ }
+ const grain = getSunburstTimeGrain(formData, column);
+ const temporalFilter = getTemporalXAxisDrillByFilter(
+ column,
+ value,
+ grain,
+ formattedVal,
+ );
+ if (!temporalFilter) {
+ return { col: column, op: '==', val: value, formattedVal };
+ }
+ if (temporalFilter.op === 'TEMPORAL_RANGE') {
+ return temporalFilter;
+ }
+ // Grains without frontend bucket arithmetic match on the grained column.
+ return {
+ col: column,
+ op: '==',
+ val: getSunburstTemporalValue(column, value),
+ formattedVal,
+ ...(grain && { grain }),
+ };
+}
+
+/**
+ * Drill to detail sends its filters to the backend unchanged, so it compares
+ * bucket labels on the grained column. Unlike TEMPORAL_RANGE, equality
+ * survives the query time_range override.
+ */
+export function getSunburstDetailFilter(
+ column: QueryFormColumn,
+ value: DataRecordValue,
+ formData: EchartsSunburstFormData,
+ coltype: GenericDataType | undefined,
+ formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+ if (coltype !== GenericDataType.Temporal) {
+ return { col: column, op: '==', val: value, formattedVal };
+ }
+ const grain = getSunburstTimeGrain(formData, column);
+ return {
+ col: column,
+ op: '==',
+ val: getSunburstTemporalValue(column, value),
+ formattedVal,
+ ...(grain && { grain }),
+ };
+}
+
+/**
+ * Drill by converts its filters to adhoc filters, which keep only the column,
+ * operator and comparator, so a grain would be dropped. A known grain
therefore
+ * selects its bucket with TEMPORAL_RANGE. As with Timeseries Drill by, a query
+ * time_range replaces that range. Other temporal values use the naive ISO
+ * datetime, which SQL expression filters compare without backend coercion.
+ */
+export function getSunburstDrillByFilter(
+ column: QueryFormColumn,
+ value: DataRecordValue,
+ formData: EchartsSunburstFormData,
+ coltype: GenericDataType | undefined,
+ formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+ const temporalFilter =
+ coltype === GenericDataType.Temporal
+ ? getTemporalXAxisDrillByFilter(
+ column,
+ value,
+ getSunburstTimeGrain(formData, column),
+ formattedVal,
+ )
+ : undefined;
+ return temporalFilter ?? { col: column, op: '==', val: value, formattedVal };
+}
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
index 39b60655f5b..57419d196a7 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
@@ -115,6 +115,24 @@ const TIMEZONE_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
const parseAsUtc = (value: string): Date =>
new Date(TIMEZONE_SUFFIX_RE.test(value) ? value : `${value}Z`);
+/**
+ * Convert a temporal record value (epoch milliseconds, naive UTC datetime
+ * string or Date) to a Date. Returns undefined for anything else.
+ */
+export const parseTemporalValue = (value: unknown): Date | undefined => {
+ let date: Date;
+ if (value instanceof Date) {
+ date = value;
+ } else if (typeof value === 'string') {
+ date = parseAsUtc(value);
+ } else if (typeof value === 'number') {
+ date = new Date(value);
+ } else {
+ return undefined;
+ }
+ return Number.isNaN(date.getTime()) ? undefined : date;
+};
+
/**
* Build a drill-by filter clause matching the clicked value on a temporal
* x-axis. When a known time grain is active, the clause is a TEMPORAL_RANGE
@@ -127,23 +145,8 @@ export const getTemporalXAxisDrillByFilter = (
grain?: TimeGranularity,
formattedVal?: string,
): BinaryQueryObjectFilterClause | undefined => {
- if (
- !col ||
- (typeof value !== 'number' &&
- typeof value !== 'string' &&
- !(value instanceof Date))
- ) {
- return undefined;
- }
- let bucketLabel: Date;
- if (value instanceof Date) {
- bucketLabel = value;
- } else if (typeof value === 'string') {
- bucketLabel = parseAsUtc(value);
- } else {
- bucketLabel = new Date(value);
- }
- if (Number.isNaN(bucketLabel.getTime())) {
+ const bucketLabel = parseTemporalValue(value);
+ if (!col || !bucketLabel) {
return undefined;
}
const range = grain ? getTimeBucketRange(bucketLabel, grain) : undefined;
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
new file mode 100644
index 00000000000..79c1f4c9ca5
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
@@ -0,0 +1,474 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render } from '@testing-library/react';
+import {
+ ChartProps,
+ QueryFormColumn,
+ TimeGranularity,
+ createSmartDateFormatter,
+ getTimeFormatterRegistry,
+ SMART_DATE_ID,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { supersetTheme } from '@apache-superset/core/theme';
+import EchartsSunburst from '../../src/Sunburst/EchartsSunburst';
+import transformProps from '../../src/Sunburst/transformProps';
+import {
+ EchartsSunburstChartProps,
+ EchartsSunburstFormData,
+ NodeItemOption,
+} from '../../src/Sunburst/types';
+import { EchartsProps } from '../../src/types';
+
+// Match the app bootstrap's formatter registration.
+beforeAll(() => {
+ getTimeFormatterRegistry().registerValue(
+ SMART_DATE_ID,
+ createSmartDateFormatter(),
+ );
+});
+
+const mockEchart = jest.fn();
+jest.mock('../../src/components/Echart', () => (props: EchartsProps) => {
+ mockEchart(props);
+ return null;
+});
+
+function setup({
+ grain,
+ override,
+ raw = Date.UTC(2024, 9, 1),
+ selectedValues = [] as string[],
+ dateFormat = 'smart_date',
+ temporalFirst = false,
+ granularitySqla = 'order_date',
+ temporalColumnsLookup,
+ timeColumnOverride,
+ adhocTemporal = false,
+}: {
+ grain?: TimeGranularity;
+ override?: TimeGranularity;
+ raw?: number | string | Date | null;
+ selectedValues?: string[];
+ dateFormat?: string;
+ temporalFirst?: boolean;
+ granularitySqla?: string;
+ temporalColumnsLookup?: Record<string, boolean>;
+ timeColumnOverride?: string;
+ adhocTemporal?: boolean;
+} = {}) {
+ const setDataMask = jest.fn();
+ const onContextMenu = jest.fn();
+ const temporalColumn: QueryFormColumn = adhocTemporal
+ ? {
+ expressionType: 'SQL',
+ sqlExpression: "DATE_TRUNC('month', order_date)",
+ label: 'order_date',
+ }
+ : 'order_date';
+ const columns = temporalFirst
+ ? [temporalColumn, 'category']
+ : ['category', temporalColumn];
+ const props = transformProps(
+ new ChartProps<EchartsSunburstFormData>({
+ formData: {
+ datasource: '1__table',
+ viz_type: 'sunburst_v2',
+ groupby: [],
+ columns,
+ metric: 'count',
+ time_range: '2024-01-01 : 2025-01-01',
+ dateFormat,
+ timeGrainSqla: grain,
+ granularitySqla,
+ temporalColumnsLookup,
+ extraFormData: {
+ ...(override && { time_grain_sqla: override }),
+ ...(timeColumnOverride && { granularity_sqla: timeColumnOverride }),
+ },
+ },
+ width: 800,
+ height: 600,
+ theme: supersetTheme,
+ queriesData: [
+ {
+ colnames: ['category', 'order_date', 'count'],
+ coltypes: [
+ GenericDataType.String,
+ GenericDataType.Temporal,
+ GenericDataType.Numeric,
+ ],
+ data: [{ category: 'A', order_date: raw, count: 10 }],
+ },
+ ],
+ hooks: { setDataMask, onContextMenu },
+ filterState: { selectedValues },
+ emitCrossFilters: true,
+ }) as EchartsSunburstChartProps,
+ );
+ render(<EchartsSunburst {...props} />);
+ const { eventHandlers } = mockEchart.mock.lastCall[0] as EchartsProps;
+ const nodes = (
+ props.echartOptions as { series: { data: NodeItemOption[] }[] }
+ ).series[0].data;
+ const path = [nodes[0], nodes[0].children?.[0] as NodeItemOption];
+ const event = (depth = 2) => ({
+ data: path[depth - 1],
+ treePathInfo: [
+ { name: '', dataIndex: 0, value: 10 },
+ ...path
+ .slice(0, depth)
+ .map((node, i) => ({ name: node.name, dataIndex: i + 1, value: 10 })),
+ ],
+ event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
+ });
+ return {
+ setDataMask,
+ onContextMenu,
+ eventHandlers: eventHandlers!,
+ event,
+ path,
+ };
+}
+
+test('cross-filters a raw temporal segment with the timestamp rather than its
label', () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ raw: Date.UTC(2024, 9, 14, 12, 30),
+ });
+ eventHandlers.click(event());
+ expect(setDataMask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ { col: 'order_date', op: '==', val: Date.UTC(2024, 9, 14, 12, 30) },
+ ],
+ },
+ }),
+ );
+});
+
+test.each([
+ [TimeGranularity.MONTH, '2024-10-01T00:00:00 : 2024-11-01T00:00:00'],
+ [TimeGranularity.YEAR, '2024-01-01T00:00:00 : 2025-01-01T00:00:00'],
+])(
+ 'cross-filters the entire %s bucket with upper-exclusive bounds',
+ (grain, range) => {
+ const raw =
+ grain === TimeGranularity.YEAR
+ ? Date.UTC(2024, 0, 1)
+ : Date.UTC(2024, 9, 1);
+ const { eventHandlers, event, setDataMask } = setup({ grain, raw });
+ eventHandlers.click(event());
+ expect(setDataMask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [{ col: 'order_date', op: 'TEMPORAL_RANGE', val: range }],
+ time_range: range,
+ },
+ }),
+ );
+ },
+);
+
+test('uses the dashboard grain override for temporal cross-filters', () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ grain: TimeGranularity.MONTH,
+ override: TimeGranularity.YEAR,
+ raw: '2024-01-01T00:00:00',
+ });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData.time_range).toBe(
+ '2024-01-01T00:00:00 : 2025-01-01T00:00:00',
+ );
+});
+
+test('keeps categorical cross-filters and click-to-clear behavior', () => {
+ const first = setup({ grain: TimeGranularity.MONTH });
+ first.eventHandlers.click(first.event(1));
+ const mask = first.setDataMask.mock.lastCall[0];
+ expect(mask).toEqual({
+ extraFormData: { filters: [{ col: 'category', op: '==', val: 'A' }] },
+ filterState: { value: 'A', selectedValues: ['A'] },
+ });
+ const second = setup({ selectedValues: mask.filterState.selectedValues });
+ second.eventHandlers.click(second.event(1));
+ expect(second.setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [],
+ });
+});
+
+test('drill-by sends an ungrained temporal value as a naive ISO datetime', ()
=> {
+ const { eventHandlers, event, onContextMenu, path } = setup();
+ eventHandlers.contextmenu(event());
+ expect(onContextMenu.mock.lastCall[2].drillBy.filters).toEqual([
+ {
+ col: 'order_date',
+ op: '==',
+ val: '2024-10-01T00:00:00',
+ formattedVal: path[1].name,
+ },
+ ]);
+});
+
+test('drill by selects a grained bucket with its TEMPORAL_RANGE', () => {
+ // Drill by keeps only column, operator and comparator, so a grain is lost.
+ const { eventHandlers, event, onContextMenu, path } = setup({
+ grain: TimeGranularity.MONTH,
+ });
+ eventHandlers.contextmenu(event());
+ const context = onContextMenu.mock.lastCall[2];
+ expect(context.drillBy.filters).toEqual([
+ {
+ col: 'order_date',
+ op: 'TEMPORAL_RANGE',
+ val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+ formattedVal: path[1].name,
+ },
+ ]);
+ expect(context.crossFilter.dataMask.extraFormData.filters[0].op).toBe(
+ 'TEMPORAL_RANGE',
+ );
+});
+
+test('drill to detail matches a grained bucket by its label and grain', () => {
+ // Samples queries replace TEMPORAL_RANGE values with the chart's time_range,
+ // so a range here would return every row of the dashboard time range.
+ const { eventHandlers, event, onContextMenu, path } = setup({
+ grain: TimeGranularity.MONTH,
+ });
+ eventHandlers.contextmenu(event());
+ expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+ col: 'order_date',
+ op: '==',
+ val: Date.UTC(2024, 9, 1),
+ grain: TimeGranularity.MONTH,
+ formattedVal: path[1].name,
+ });
+});
+
+test('temporal segments with identical custom labels do not toggle each other
off', () => {
+ const first = setup({ raw: Date.UTC(2023, 9, 1), dateFormat: '%B' });
+ first.eventHandlers.click(first.event());
+ const second = setup({
+ raw: Date.UTC(2024, 9, 1),
+ dateFormat: '%B',
+ selectedValues:
+ first.setDataMask.mock.lastCall[0].filterState.selectedValues,
+ });
+ second.eventHandlers.click(second.event());
+ expect(
+ second.setDataMask.mock.lastCall[0].extraFormData.filters,
+ ).toHaveLength(1);
+});
+
+test('cross-filters a temporal level before a categorical level', () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ grain: TimeGranularity.MONTH,
+ temporalFirst: true,
+ });
+ eventHandlers.click(event(1));
+ expect(setDataMask.mock.lastCall[0].extraFormData.filters[0]).toMatchObject({
+ col: 'order_date',
+ op: 'TEMPORAL_RANGE',
+ });
+});
+
+test('uses the temporal hierarchy lookup without a legacy granularity
subject', () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ grain: TimeGranularity.MONTH,
+ granularitySqla: '',
+ temporalColumnsLookup: { order_date: true },
+ });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData.filters[0].op).toBe(
+ 'TEMPORAL_RANGE',
+ );
+});
+
+test("does not apply another temporal column's grain to a raw level", () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ grain: TimeGranularity.MONTH,
+ granularitySqla: 'other_date',
+ });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [{ col: 'order_date', op: '==', val: Date.UTC(2024, 9, 1) }],
+ });
+});
+
+test('clears the temporal range override when clicking the selected bucket',
() => {
+ const first = setup({ grain: TimeGranularity.MONTH });
+ first.eventHandlers.click(first.event());
+ const second = setup({
+ grain: TimeGranularity.MONTH,
+ selectedValues:
+ first.setDataMask.mock.lastCall[0].filterState.selectedValues,
+ });
+ second.eventHandlers.click(second.event());
+ expect(second.setDataMask.mock.lastCall[0]).toEqual({
+ extraFormData: { filters: [] },
+ filterState: { value: null, selectedValues: [] },
+ });
+});
+
+test('keeps a null temporal value rather than filtering the null display
label', () => {
+ const { eventHandlers, event, setDataMask } = setup({
+ raw: null,
+ grain: TimeGranularity.MONTH,
+ });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [{ col: 'order_date', op: '==', val: null }],
+ });
+});
+
+test('keeps categorical child filters unchanged beneath a temporal ancestor',
() => {
+ const { eventHandlers, event, setDataMask } = setup({
+ grain: TimeGranularity.MONTH,
+ temporalFirst: true,
+ });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [{ col: 'category', op: '==', val: 'A' }],
+ });
+});
+
+test('grains the level chosen by a dashboard Time Column filter', () => {
+ const { eventHandlers, event, setDataMask, path } = setup({
+ granularitySqla: '',
+ override: TimeGranularity.MONTH,
+ timeColumnOverride: 'order_date',
+ });
+ expect(path[1].name).toBe('Oct 2024');
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [
+ {
+ col: 'order_date',
+ op: 'TEMPORAL_RANGE',
+ val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+ },
+ ],
+ time_range: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+ });
+});
+
+test('uses only the effective dashboard time column, not the saved one', () =>
{
+ const { eventHandlers, event, setDataMask, path } = setup({
+ grain: TimeGranularity.MONTH,
+ timeColumnOverride: 'other_date',
+ });
+ expect(path[1].name).toBe('October');
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [{ col: 'order_date', op: '==', val: Date.UTC(2024, 9, 1) }],
+ });
+});
+
+test.each(['PT6H', 'PT5S', 'PT30S', 'PT0.5H', 'P0.25Y', 'custom_grain'])(
+ 'preserves the engine grain %s for cross-filter and drill equality',
+ grain => {
+ const { eventHandlers, event, setDataMask, onContextMenu, path } = setup({
+ grain: grain as TimeGranularity,
+ raw: Date.UTC(2024, 9, 7, 6),
+ });
+ expect(path[1].name).toBe('06 AM');
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [
+ { col: 'order_date', op: '==', val: Date.UTC(2024, 9, 7, 6), grain },
+ ],
+ });
+ eventHandlers.contextmenu(event());
+ expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+ col: 'order_date',
+ op: '==',
+ val: Date.UTC(2024, 9, 7, 6),
+ grain,
+ formattedVal: path[1].name,
+ });
+ // Drill by cannot carry a grain, so it matches the bucket start instant.
+ expect(onContextMenu.mock.lastCall[2].drillBy.filters).toEqual([
+ {
+ col: 'order_date',
+ op: '==',
+ val: '2024-10-07T06:00:00',
+ formattedVal: path[1].name,
+ },
+ ]);
+ },
+);
+
+test('Date selection keys use epoch milliseconds', () => {
+ const raw = new Date(Date.UTC(2024, 9, 1));
+ const { eventHandlers, event, setDataMask } = setup({ raw });
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].filterState.selectedValues).toEqual([
+ `A,${raw.getTime()}`,
+ ]);
+});
+
+test('legacy label-based temporal selections remain highlighted and click to
clear', () => {
+ const { eventHandlers, event, setDataMask, path } = setup({
+ selectedValues: ['A,October'],
+ });
+ expect(path[1].itemStyle?.opacity).not.toBe(0.3);
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({ filters: [] });
+});
+
+test('ungrained drill to detail keeps the epoch value for native engine
coercion', () => {
+ const raw = Date.UTC(2003, 10, 14);
+ const { eventHandlers, event, onContextMenu, path } = setup({ raw });
+ eventHandlers.contextmenu(event());
+ expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+ col: 'order_date',
+ op: '==',
+ val: raw,
+ formattedVal: path[1].name,
+ });
+});
+
+test('adhoc temporal levels compare a naive ISO datetime, not an epoch
integer', () => {
+ // An adhoc SQL expression has no native type for the backend to convert an
+ // epoch into, so `(expr) = 1727740800000` would fail on typed engines.
+ const { eventHandlers, event, onContextMenu, setDataMask, path } = setup({
+ adhocTemporal: true,
+ grain: TimeGranularity.MONTH,
+ });
+ const col = {
+ expressionType: 'SQL',
+ sqlExpression: "DATE_TRUNC('month', order_date)",
+ label: 'order_date',
+ };
+ eventHandlers.click(event());
+ expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+ filters: [{ col, op: '==', val: '2024-10-01T00:00:00' }],
+ });
+ eventHandlers.contextmenu(event());
+ const context = onContextMenu.mock.lastCall[2];
+ for (const filter of [context.drillToDetail[1], context.drillBy.filters[0]])
{
+ expect(filter).toEqual({
+ col,
+ op: '==',
+ val: '2024-10-01T00:00:00',
+ formattedVal: path[1].name,
+ });
+ }
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
index 6980bc5608a..8d06d721b79 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
@@ -16,15 +16,33 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { ChartProps } from '@superset-ui/core';
+import {
+ ChartProps,
+ TimeGranularity,
+ createSmartDateFormatter,
+ getTimeFormatterRegistry,
+ SMART_DATE_ID,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
import { supersetTheme } from '@apache-superset/core/theme';
-import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
+import {
+ EchartsSunburstChartProps,
+ EchartsSunburstFormData,
+} from '../../src/Sunburst/types';
import transformProps from '../../src/Sunburst/transformProps';
type SunburstSeries = {
label?: Record<string, unknown>;
data: { value: number }[];
};
+// Match the app bootstrap's formatter registration.
+beforeAll(() => {
+ getTimeFormatterRegistry().registerValue(
+ SMART_DATE_ID,
+ createSmartDateFormatter(),
+ );
+});
+
const firstSeries = (echartOptions: unknown) =>
(echartOptions as { series: SunburstSeries[] }).series[0];
@@ -103,3 +121,130 @@ test('keeps null values when showNullValues is true', ()
=> {
test('removes null values when showNullValues is false', () => {
expect(seriesValues(nullValueProps(false))).toEqual([10, 20]);
});
+
+test.each([
+ TimeGranularity.MONTH,
+ TimeGranularity.QUARTER,
+ TimeGranularity.YEAR,
+])('smart_date labels distinguish %s buckets across years', grain => {
+ const month = grain === TimeGranularity.YEAR ? 0 : 9;
+ const props = new ChartProps<EchartsSunburstFormData>({
+ formData: {
+ ...formData,
+ viz_type: 'sunburst_v2',
+ columns: ['order_date'],
+ dateFormat: 'smart_date',
+ timeGrainSqla: grain,
+ granularitySqla: 'order_date',
+ },
+ width: 800,
+ height: 600,
+ theme: supersetTheme,
+ queriesData: [
+ {
+ colnames: ['order_date', 'sum__value'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ data: [
+ { order_date: Date.UTC(2023, month, 1), sum__value: 10 },
+ { order_date: Date.UTC(2024, month, 1), sum__value: 20 },
+ ],
+ },
+ ],
+ });
+ const result = transformProps(props as EchartsSunburstChartProps);
+ const nodes = (
+ result.echartOptions as {
+ series: { data: { name: string; records: number[] }[] }[];
+ }
+ ).series[0].data;
+ expect(nodes[0].name).toContain('2023');
+ expect(nodes[1].name).toContain('2024');
+ expect(nodes[0].name).not.toBe(nodes[1].name);
+ expect(nodes.map(node => node.records[0])).toEqual([
+ Date.UTC(2023, month, 1),
+ Date.UTC(2024, month, 1),
+ ]);
+});
+
+test.each([
+ [TimeGranularity.WEEK, Date.UTC(2024, 9, 7), 'Mon 07'],
+ [TimeGranularity.DAY, Date.UTC(2024, 9, 7), 'Mon 07'],
+ [TimeGranularity.HOUR, Date.UTC(2024, 9, 7, 6), '06 AM'],
+])('keeps compact smart_date labels for %s', (grain, raw, label) => {
+ const props = new ChartProps<EchartsSunburstFormData>({
+ formData: {
+ ...formData,
+ viz_type: 'sunburst_v2',
+ columns: ['order_date'],
+ dateFormat: 'smart_date',
+ timeGrainSqla: grain,
+ granularitySqla: 'order_date',
+ },
+ width: 800,
+ height: 600,
+ theme: supersetTheme,
+ queriesData: [
+ {
+ colnames: ['order_date', 'sum__value'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+ data: [{ order_date: raw, sum__value: 10 }],
+ },
+ ],
+ });
+ const result = transformProps(props as EchartsSunburstChartProps);
+ expect(
+ (result.echartOptions as { series: { data: { name: string }[] }[] })
+ .series[0].data[0].name,
+ ).toBe(label);
+});
+
+test('a year label is not highlighted by digits inside a selected epoch key',
() => {
+ const y2000 = Date.UTC(2000, 0, 1);
+ const y2004 = Date.UTC(2004, 0, 1);
+ // The selected key holds 1072915200000, which contains the digits "2000".
+ expect(String(y2004)).toContain('2000');
+ const props = new ChartProps<EchartsSunburstFormData>({
+ formData: {
+ ...formData,
+ viz_type: 'sunburst_v2',
+ columns: ['order_date', 'category'],
+ dateFormat: 'smart_date',
+ timeGrainSqla: TimeGranularity.YEAR,
+ granularitySqla: 'order_date',
+ },
+ width: 800,
+ height: 600,
+ theme: supersetTheme,
+ filterState: { selectedValues: [`${y2004},A`] },
+ queriesData: [
+ {
+ colnames: ['order_date', 'category', 'sum__value'],
+ coltypes: [
+ GenericDataType.Temporal,
+ GenericDataType.String,
+ GenericDataType.Numeric,
+ ],
+ data: [
+ { order_date: y2000, category: 'A', sum__value: 10 },
+ { order_date: y2004, category: 'A', sum__value: 20 },
+ ],
+ },
+ ],
+ });
+ const nodes = (
+ transformProps(props as EchartsSunburstChartProps).echartOptions as {
+ series: {
+ data: {
+ name: string;
+ itemStyle?: { opacity?: number };
+ children?: { itemStyle?: { opacity?: number } }[];
+ }[];
+ }[];
+ }
+ ).series[0].data;
+ expect(nodes.map(node => node.name)).toEqual(['2000', '2004']);
+ expect(nodes[0].itemStyle?.opacity).toBe(0.3);
+ expect(nodes[0].children?.[0].itemStyle?.opacity).toBe(0.3);
+ expect(nodes[1].itemStyle?.opacity).toBeUndefined();
+ expect(nodes[1].children?.[0].itemStyle?.opacity).toBeUndefined();
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts
new file mode 100644
index 00000000000..f15df8e9a13
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { TimeGranularity } from '@superset-ui/core';
+import { getSunburstTimeGrain } from '../../src/Sunburst/utils';
+import { EchartsSunburstFormData } from '../../src/Sunburst/types';
+
+const formData: EchartsSunburstFormData = {
+ datasource: '1__table',
+ viz_type: 'sunburst_v2',
+ groupby: [],
+ metric: 'count',
+ columns: ['d1', 'd2'],
+ temporalColumnsLookup: { d1: true, d2: true },
+ timeGrainSqla: TimeGranularity.MONTH,
+};
+
+test('does not grain ambiguous semantic hierarchies with two temporal levels',
() => {
+ expect(getSunburstTimeGrain(formData, 'd1')).toBeUndefined();
+ expect(getSunburstTimeGrain(formData, 'd2')).toBeUndefined();
+});
+
+test('dashboard time column takes precedence in a two-temporal-level
hierarchy', () => {
+ const overridden = {
+ ...formData,
+ granularitySqla: 'd1',
+ extraFormData: { granularity_sqla: 'd2' },
+ };
+ expect(getSunburstTimeGrain(overridden, 'd1')).toBeUndefined();
+ expect(getSunburstTimeGrain(overridden, 'd2')).toBe(TimeGranularity.MONTH);
+});
+
+test('ignores unselected temporal columns when identifying a semantic time
axis', () => {
+ expect(getSunburstTimeGrain({ ...formData, columns: ['d1'] }, 'd1')).toBe(
+ TimeGranularity.MONTH,
+ );
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
index b841868cc28..f5b09e3b3fa 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
@@ -23,6 +23,7 @@ import { supersetTheme, ThemeProvider } from
'@apache-superset/core/theme';
import { logging } from '@apache-superset/core/utils';
import type { ECElementEvent } from 'echarts/types/src/util/types';
import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
+import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
import {
OrientationType,
TimeseriesChartTransformedProps,
@@ -897,3 +898,42 @@ test('context menu cross-filter is available for a
temporal bar point', async ()
},
]);
});
+
+test.each([undefined, 'PT6H' as TimeGranularity])(
+ 'temporal drill by compares a naive ISO datetime for grain %s',
+ async grain => {
+ const onContextMenu = jest.fn();
+ renderTimeseries({
+ onContextMenu,
+ groupby: [],
+ formData: {
+ ...DEFAULT_FORM_DATA,
+ datasource: '1__table',
+ viz_type: 'echarts_timeseries_line',
+ metric: 'count',
+ groupby: [],
+ granularitySqla: 'ds',
+ timeGrainSqla: grain,
+ },
+ xAxis: { label: DTTM_ALIAS, type: AxisType.Time },
+ });
+ const raw = Date.UTC(2024, 9, 7, 6);
+ await getLatestEchartProps().eventHandlers?.contextmenu?.({
+ componentType: 'series',
+ seriesName: 'Sales',
+ data: [raw, 100],
+ name: '2024-10-07',
+ event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+ });
+ // Drill by writes adhoc x-axis comparators straight into SQL, where an
+ // epoch integer would not compare with a timestamp.
+ expect(onContextMenu.mock.lastCall[2].drillBy.xAxisFilters).toEqual([
+ {
+ col: 'ds',
+ op: '==',
+ val: '2024-10-07T06:00:00',
+ formattedVal: expect.any(String),
+ },
+ ]);
+ },
+);
diff --git
a/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
b/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
new file mode 100644
index 00000000000..a632514992b
--- /dev/null
+++
b/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
@@ -0,0 +1,256 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render as renderChart } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import {
+ BinaryQueryObjectFilterClause,
+ ChartProps,
+ QueryFormColumn,
+ QueryFormData,
+ TimeGranularity,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { supersetTheme } from '@apache-superset/core/theme';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import mockState from 'spec/fixtures/mockState';
+import { setupAGGridModules } from
'@superset-ui/core/components/ThemedAgGridReact';
+import EchartsSunburst from
'../../../../plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst';
+import transformProps from
'../../../../plugins/plugin-chart-echarts/src/Sunburst/transformProps';
+import {
+ EchartsSunburstChartProps,
+ EchartsSunburstFormData,
+} from '../../../../plugins/plugin-chart-echarts/src/Sunburst/types';
+import { getTemporalXAxisDrillByFilter } from
'../../../../plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter';
+import type { EchartsProps } from
'../../../../plugins/plugin-chart-echarts/src/types';
+import DrillByModal from './DrillByModal';
+
+setupAGGridModules();
+
+const mockEchart = jest.fn();
+jest.mock(
+ '../../../../plugins/plugin-chart-echarts/src/components/Echart',
+ () => (props: EchartsProps) => {
+ mockEchart(props);
+ return null;
+ },
+);
+
+const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
+
+const ADHOC_ORDER_DATE: QueryFormColumn = {
+ expressionType: 'SQL',
+ sqlExpression: "DATE_TRUNC('month', order_date)",
+ label: 'order_date',
+};
+
+const sunburstFormData = (
+ temporalColumn: QueryFormColumn,
+): EchartsSunburstFormData => ({
+ datasource: '7__table',
+ viz_type: 'sunburst_v2',
+ slice_id: 101,
+ groupby: [],
+ columns: ['category', temporalColumn],
+ metric: 'count',
+ adhoc_filters: [],
+ dateFormat: 'smart_date',
+ timeGrainSqla: TimeGranularity.MONTH,
+ granularitySqla: 'order_date',
+});
+
+/** Right-click the Oct 2024 segment of a rendered Sunburst chart. */
+const sunburstDrillByFilters = (temporalColumn: QueryFormColumn) => {
+ const onContextMenu = jest.fn();
+ const props = transformProps(
+ new ChartProps<EchartsSunburstFormData>({
+ formData: sunburstFormData(temporalColumn),
+ width: 800,
+ height: 600,
+ theme: supersetTheme,
+ queriesData: [
+ {
+ colnames: ['category', 'order_date', 'count'],
+ coltypes: [
+ GenericDataType.String,
+ GenericDataType.Temporal,
+ GenericDataType.Numeric,
+ ],
+ data: [{ category: 'A', order_date: Date.UTC(2024, 9, 1), count: 3
}],
+ },
+ ],
+ hooks: { onContextMenu },
+ }) as EchartsSunburstChartProps,
+ );
+ renderChart(<EchartsSunburst {...props} />);
+ const { eventHandlers } = mockEchart.mock.lastCall[0] as EchartsProps;
+ const parent = (
+ props.echartOptions as {
+ series: { data: { name: string; children: { name: string }[] }[] }[];
+ }
+ ).series[0].data[0];
+ const segment = parent.children[0];
+ eventHandlers!.contextmenu({
+ data: segment,
+ treePathInfo: [
+ { name: '', dataIndex: 0, value: 3 },
+ { name: parent.name, dataIndex: 1, value: 3 },
+ { name: segment.name, dataIndex: 2, value: 3 },
+ ],
+ event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
+ });
+ return onContextMenu.mock.lastCall[2].drillBy
+ .filters as BinaryQueryObjectFilterClause[];
+};
+
+/** Open Drill by with the filters and return the chart-data request body. */
+const drillByRequest = async (
+ formData: QueryFormData,
+ filters: BinaryQueryObjectFilterClause[],
+ groupbyFieldName: string,
+) => {
+ render(
+ <DrillByModal
+ formData={formData}
+ onHideModal={jest.fn()}
+ dataset={{
+ changed_on_humanized: '01-01-2001',
+ created_on_humanized: '01-01-2001',
+ description: 'desc',
+ table_name: 'my_dataset',
+ editors: [],
+ columns: [
+ { column_name: 'category', verbose_name: null },
+ { column_name: 'region', verbose_name: null },
+ ],
+ verbose_map: {},
+ }}
+ column={{ column_name: 'region', verbose_name: null }}
+ drillByConfig={{ groupbyFieldName, filters }}
+ canDownload={false}
+ />,
+ { useDnd: true, useRedux: true, useRouter: true, initialState: mockState },
+ );
+ await screen.findByRole('dialog');
+ await waitFor(() =>
+ expect(fetchMock.callHistory.called(CHART_DATA_ENDPOINT)).toBe(true),
+ );
+ return JSON.parse(
+ fetchMock.callHistory.lastCall(CHART_DATA_ENDPOINT)?.options
+ ?.body as string,
+ );
+};
+
+beforeEach(() => {
+ fetchMock.post(
+ CHART_DATA_ENDPOINT,
+ { body: {} },
+ { name: CHART_DATA_ENDPOINT },
+ );
+});
+
+afterEach(() => {
+ fetchMock.removeRoutes().clearHistory();
+ mockEchart.mockClear();
+});
+
+test('Sunburst Drill by on a grained segment queries the whole bucket', async
() => {
+ const filters = sunburstDrillByFilters('order_date');
+ const body = await drillByRequest(
+ sunburstFormData('order_date'),
+ filters,
+ 'columns',
+ );
+ // A grain on an equality filter would not survive simpleFilterToAdhoc and
+ // convertFilter, leaving `order_date = <month start>`.
+ expect(body.queries[0].filters).toContainEqual({
+ col: 'order_date',
+ op: 'TEMPORAL_RANGE',
+ val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+ });
+ expect(body.queries[0].filters).not.toContainEqual(
+ expect.objectContaining({ col: 'order_date', op: '==' }),
+ );
+});
+
+test('Sunburst Drill by on an adhoc temporal segment writes a quoted ISO
datetime', async () => {
+ const filters = sunburstDrillByFilters(ADHOC_ORDER_DATE);
+ const body = await drillByRequest(
+ sunburstFormData(ADHOC_ORDER_DATE),
+ filters,
+ 'columns',
+ );
+ expect(body.form_data.adhoc_filters).toContainEqual(
+ expect.objectContaining({
+ expressionType: 'SQL',
+ sqlExpression:
+ "(DATE_TRUNC('month', order_date)) = '2024-10-01T00:00:00'",
+ }),
+ );
+});
+
+test('Timeseries Drill by on an adhoc x-axis label sends an ISO datetime, not
an epoch', async () => {
+ const xAxis: QueryFormColumn = {
+ expressionType: 'SQL',
+ sqlExpression: "DATE_TRUNC('day', order_date)",
+ label: 'order_day',
+ };
+ // Both Timeseries Drill by callers use this helper with the axis label.
+ const filters = [
+ getTemporalXAxisDrillByFilter('order_day', Date.UTC(2024, 9, 7)),
+ getTemporalXAxisDrillByFilter(
+ 'order_day',
+ Date.UTC(2024, 9, 7, 6),
+ 'PT6H' as TimeGranularity,
+ ),
+ ] as BinaryQueryObjectFilterClause[];
+ const body = await drillByRequest(
+ {
+ datasource: '7__table',
+ viz_type: 'echarts_timeseries_line',
+ slice_id: 102,
+ x_axis: xAxis,
+ groupby: [],
+ metrics: ['count'],
+ adhoc_filters: [],
+ },
+ filters,
+ 'groupby',
+ );
+ // The backend writes an adhoc label comparator without coercion, so an
+ // epoch integer would reach SQL as `<timestamp expr> = 1728259200000`.
+ expect(body.queries[0].filters).toEqual(
+ expect.arrayContaining([
+ { col: 'order_day', op: '==', val: '2024-10-07T00:00:00' },
+ { col: 'order_day', op: '==', val: '2024-10-07T06:00:00' },
+ ]),
+ );
+});
+
+test('Drill by drops the grain of an equality filter', async () => {
+ const body = await drillByRequest(
+ sunburstFormData('order_date'),
+ [{ col: 'order_date', op: '==', val: 1727740800000, grain: 'P1M' }],
+ 'columns',
+ );
+ expect(body.queries[0].filters).toContainEqual({
+ col: 'order_date',
+ op: '==',
+ val: 1727740800000,
+ });
+});
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 12180f82775..483b6b4ad30 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -2044,6 +2044,19 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
dataset_timezone = extra.get("timezone")
return dataset_timezone if isinstance(dataset_timezone, str) else None
+ def _has_valid_dataset_timezone(self) -> bool:
+ """
+ Whether the dataset timezone resolves. ``normalize_dttm_col`` applies
the
+ Hours offset instead when it does not, so filters must invert it too.
+ """
+ if not (dataset_timezone := self.get_dataset_timezone()):
+ return False
+ try:
+ ZoneInfo(dataset_timezone)
+ except (ZoneInfoNotFoundError, ValueError):
+ return False
+ return True
+
def get_fetch_values_predicate(
self,
template_processor: Optional[ # pylint: disable=unused-argument
@@ -4187,6 +4200,7 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
builtins.type["BaseEngineSpec"]
] = None, # fix(hughhh): Optional[Type[BaseEngineSpec]]
db_extra: Optional[dict[str, Any]] = None,
+ temporal_offset_hours: int = 0,
) -> Optional[FilterValues]:
if values is None:
return None
@@ -4232,9 +4246,31 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
)
return literal_column(temporal_sql) if temporal_sql is not None
else value
+ def shift_temporal_value(value: Optional[FilterValue]) ->
Optional[FilterValue]:
+ """Undo the display-only offset before comparing an unshifted
bucket."""
+ if (
+ not temporal_offset_hours
+ or target_generic_type != GenericDataType.TEMPORAL
+ ):
+ return value
+ if isinstance(value, bool) or value is None:
+ return value
+ if isinstance(value, (int, float)):
+ return value - temporal_offset_hours * 60 * 60 * 1000
+ delta = timedelta(hours=temporal_offset_hours)
+ if isinstance(value, datetime):
+ return value - delta
+ if re.fullmatch(r"[+-]?\d+", value):
+ return str(int(value) - temporal_offset_hours * 60 * 60 * 1000)
+ try:
+ return (datetime.fromisoformat(value) - delta).isoformat(sep="
")
+ except ValueError:
+ return value
+
def handle_single_value(value: Optional[FilterValue]) ->
Optional[FilterValue]:
if operator == utils.FilterOperator.TEMPORAL_RANGE:
return value
+ value = shift_temporal_value(value)
if isinstance(value, str):
value = value.strip("\t\n")
@@ -5394,6 +5430,8 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
flt_col = flt["col"]
val = flt.get("val")
flt_grain = flt.get("grain")
+ filter_grain = flt_grain
+ adhoc_filter_column: AdhocColumn | None = None
op = utils.FilterOperator(flt["op"].upper())
col_obj: Optional["TableColumn"] = None
sqla_col: Optional[Column] = None
@@ -5408,11 +5446,15 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
col_obj = dttm_col
elif is_adhoc_column(flt_col):
+ adhoc_filter_column = cast(AdhocColumn, flt_col)
+ if flt_col.get("columnType") == "BASE_AXIS":
+ filter_grain = flt_col.get("timeGrain")
try:
sqla_col, adhoc_generic_type = self.adhoc_column_to_sqla(
cast("AdhocColumn", flt_col),
force_type_check=True,
template_processor=template_processor,
+ apply_dataset_offset=op !=
utils.FilterOperator.TEMPORAL_RANGE,
)
applied_adhoc_filters_columns.append(flt_col)
is_adhoc_sqla_col = True
@@ -5447,16 +5489,18 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
and isinstance(flt_col, str)
and flt_col in adhoc_columns_by_label
):
+ adhoc_filter_column = adhoc_columns_by_label[flt_col]
+ if adhoc_filter_column.get("columnType") == "BASE_AXIS":
+ filter_grain = adhoc_filter_column.get("timeGrain")
sqla_col, _unused = self.adhoc_column_to_sqla(
- col=adhoc_columns_by_label[flt_col],
+ col=adhoc_filter_column,
template_processor=template_processor,
+ apply_dataset_offset=op !=
utils.FilterOperator.TEMPORAL_RANGE,
)
if isinstance(sqla_col, ColumnElement):
applied_adhoc_filters_columns.append(flt_col)
is_adhoc_sqla_col = True
- filter_grain = flt.get("grain")
-
# Check if this filter should be skipped because it was handled in
# template. Special handling for __timestamp alias: check both the
# alias and the actual column name
@@ -5482,7 +5526,11 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
pass
elif col_obj and filter_grain:
sqla_col = col_obj.get_timestamp_expression(
- time_grain=filter_grain,
template_processor=template_processor
+ time_grain=filter_grain,
+ template_processor=template_processor,
+ # Bucket comparisons must use the grouping expression.
+ # Range bounds are shifted separately by
get_time_filter.
+ apply_dataset_offset=op !=
utils.FilterOperator.TEMPORAL_RANGE,
)
elif col_obj:
sqla_col = self.convert_tbl_column_to_sqla_col(
@@ -5535,6 +5583,13 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
target_generic_type = adhoc_generic_type
else:
target_generic_type = GenericDataType.STRING
+ # Adhoc aliases absent from dataset metadata are not normalized
+ # by _collect_dttm_labels, so their buckets have no pandas
offset.
+ offset_column = (
+
columns_by_name.get(utils.get_column_name(adhoc_filter_column))
+ if adhoc_filter_column
+ else col_obj
+ )
eq = self.filter_values_handler(
values=val,
operator=op,
@@ -5543,6 +5598,34 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
is_list_target=is_list_target,
db_engine_spec=db_engine_spec,
db_extra=self.db_extra,
+ # Engines without SQL shifts group raw timestamps, then add
+ # Hours offset in pandas. Invert that display-only shift on
+ # each selected bucket, not on the underlying column.
+ temporal_offset_hours=(
+ (self.offset or 0)
+ if filter_grain
+ and offset_column is not None
+ and offset_column.is_dttm
+ and op
+ in {
+ utils.FilterOperator.EQUALS,
+ utils.FilterOperator.NOT_EQUALS,
+ utils.FilterOperator.IN,
+ utils.FilterOperator.NOT_IN,
+ utils.FilterOperator.GREATER_THAN,
+ utils.FilterOperator.LESS_THAN,
+ utils.FilterOperator.GREATER_THAN_OR_EQUALS,
+ utils.FilterOperator.LESS_THAN_OR_EQUALS,
+ }
+ # A SQL-shifted grouping already matches the label.
+ and not (
+ db_engine_spec.supports_temporal_column_shift
+ and not self.get_dataset_timezone()
+ )
+ # pandas skips the offset only for a valid timezone.
+ and not self._has_valid_dataset_timezone()
+ else 0
+ ),
)
if op in {
utils.FilterOperator.IN,
diff --git a/tests/unit_tests/models/test_hours_offset_bound_truncation.py
b/tests/unit_tests/models/test_hours_offset_bound_truncation.py
index 1f0dcbb8414..3c086fbe8ed 100644
--- a/tests/unit_tests/models/test_hours_offset_bound_truncation.py
+++ b/tests/unit_tests/models/test_hours_offset_bound_truncation.py
@@ -47,7 +47,9 @@ from superset.db_engine_specs.base import BaseEngineSpec
from superset.db_engine_specs.postgres import PostgresEngineSpec
from superset.db_engine_specs.sqlite import SqliteEngineSpec
from superset.models.core import Database
-from superset.superset_typing import AdhocColumn, QueryObjectDict
+from superset.models.helpers import ExploreMixin
+from superset.superset_typing import AdhocColumn, FilterValues, QueryObjectDict
+from superset.utils.core import GenericDataType
# ---------------------------------------------------------------------------
# Defect 1 -- DATE-column filter bound literal truncated to day precision
@@ -542,10 +544,10 @@ def test_grained_physical_filter_sql_is_unchanged(
assert query.sql_shifted_temporal_labels == set()
-def test_grained_adhoc_filter_sql_is_unchanged(
+def test_grained_adhoc_equality_filter_applies_dataset_offset(
mocker: MockerFixture, app: Flask
) -> None:
- """A BASE_AXIS-shaped adhoc filter does not opt into the axis-only
shift."""
+ """A BASE_AXIS equality filter shifts before truncation, like its
grouping."""
table, _engine = _sqlite_dataset(
mocker,
offset=1,
@@ -578,7 +580,8 @@ def test_grained_adhoc_filter_sql_is_unchanged(
assert query.sql == (
"SELECT val AS val \n"
"FROM events \n"
- "WHERE (DATETIME(ts, 'start of day')) = '2026-08-02 00:00:00' GROUP BY
val"
+ "WHERE (DATETIME(DATETIME(ts, '+1 hours'), 'start of day')) "
+ "= '2026-08-02 00:00:00' GROUP BY val"
)
assert query.sql_shifted_temporal_labels == set()
@@ -700,3 +703,472 @@ def
test_hours_offset_is_applied_before_time_grain_truncation(
assert bucket_days == {date(2026, 8, 2)}, (
f"All rows should bucket to 2026-08-02; got {sorted(bucket_days)}"
)
+
+
[email protected]("adhoc", [False, True])
[email protected]("offset", [1, -1])
+def test_grained_drill_equality_returns_the_segment_rows_with_hours_offset(
+ mocker: MockerFixture,
+ offset: int,
+ adhoc: bool,
+) -> None:
+ """Drill equality uses the same shifted bucket as the segment grouping."""
+ table, _engine = _sqlite_dataset(
+ mocker,
+ offset=offset,
+ column_type="TIMESTAMP",
+ rows=[
+ "2026-08-01 23:30:00",
+ "2026-08-02 00:30:00",
+ "2026-08-02 01:30:00",
+ "2026-08-02 23:30:00",
+ ],
+ )
+ grouped = table.get_query_result(_physical_axis_query(table,
time_grain="P1D"))
+ segment = grouped.df.loc[grouped.df["__timestamp"] ==
pd.Timestamp("2026-08-02")]
+ expected_count = 3 if offset == 1 else 2
+ assert segment["ct"].tolist() == [expected_count]
+ col: str | AdhocColumn = "ts"
+ if adhoc:
+ col = {
+ "sqlExpression": "ts",
+ "label": "ts",
+ "isColumnReference": True,
+ "columnType": "BASE_AXIS",
+ "timeGrain": "P1D",
+ }
+ query = QueryObject(
+ datasource=table,
+ columns=["ts", "val"],
+ metrics=[],
+ is_timeseries=False,
+ filters=[
+ {
+ "col": col,
+ "op": "==",
+ "val": "2026-08-02 00:00:00" if adhoc else 1785628800000,
+ "grain": "P1D",
+ }
+ ],
+ )
+ detail = table.get_query_result(query)
+ assert len(detail.df) == expected_count
+ assert detail.df["val"].tolist() == [1] * expected_count
+ expected_rows = (
+ ["2026-08-01 23:30:00", "2026-08-02 00:30:00", "2026-08-02 01:30:00"]
+ if offset == 1
+ else ["2026-08-02 01:30:00", "2026-08-02 23:30:00"]
+ )
+ assert sorted(pd.to_datetime(detail.df["ts"]).tolist()) == [
+ pd.Timestamp(value) for value in expected_rows
+ ]
+
+
[email protected]("offset,expected_count", [(1, 3), (-1, 2)])
+def test_grained_drill_filter_by_adhoc_label_applies_hours_offset(
+ mocker: MockerFixture,
+ offset: int,
+ expected_count: int,
+) -> None:
+ """A filter referring to an adhoc axis by label uses its shifted
expression."""
+ table, _engine = _sqlite_dataset(
+ mocker,
+ offset=offset,
+ column_type="TIMESTAMP",
+ rows=[
+ "2026-08-01 23:30:00",
+ "2026-08-02 00:30:00",
+ "2026-08-02 01:30:00",
+ "2026-08-02 23:30:00",
+ ],
+ )
+ axis: AdhocColumn = {
+ "sqlExpression": "ts",
+ "label": "bucket",
+ "isColumnReference": True,
+ "columnType": "BASE_AXIS",
+ "timeGrain": "P1D",
+ }
+ result = table.get_query_result(
+ QueryObject(
+ datasource=table,
+ columns=[axis],
+ metrics=[
+ {"expressionType": "SQL", "sqlExpression": "COUNT(*)",
"label": "ct"}
+ ],
+ is_timeseries=False,
+ filters=[{"col": "bucket", "op": "==", "val": "2026-08-02
00:00:00"}],
+ )
+ )
+ assert result.df["ct"].tolist() == [expected_count]
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected](
+ "filter_path", ["physical", "verbose", "timestamp", "adhoc", "label"]
+)
[email protected]("offset", [0, 1, -1, 25, -25])
[email protected]("operator", ["==", "!=", "IN", "NOT IN", ">", "<",
">=", "<="])
+def test_grained_drill_matches_every_bucket_with_hours_offset(
+ mocker: MockerFixture,
+ supports_sql_shift: bool,
+ filter_path: str,
+ offset: int,
+ operator: str,
+) -> None:
+ """Bucket comparisons invert display-only offsets for every operand."""
+ mocker.patch.object(
+ SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+ )
+ raw_rows = [
+ "2026-08-01 23:30:00",
+ "2026-08-02 00:30:00",
+ "2026-08-02 01:30:00",
+ "2026-08-02 23:30:00",
+ ]
+ table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+ table.columns[0].verbose_name = "Event time"
+ axis: AdhocColumn = {
+ "sqlExpression": "ts",
+ "label": "bucket" if filter_path == "label" else "ts",
+ "isColumnReference": True,
+ "columnType": "BASE_AXIS",
+ "timeGrain": "P1D",
+ }
+ grouped_query = _physical_axis_query(table, time_grain="P1D")
+ if filter_path in {"adhoc", "label"}:
+ grouped_query = QueryObject(
+ datasource=table,
+ columns=[axis],
+ metrics=[
+ {"expressionType": "SQL", "sqlExpression": "COUNT(*)",
"label": "ct"}
+ ],
+ is_timeseries=False,
+ )
+ grouped = table.get_query_result(grouped_query)
+ bucket_label = (
+ str(axis["label"]) if filter_path in {"adhoc", "label"} else
"__timestamp"
+ )
+ assert grouped.df["ct"].sum() == len(raw_rows)
+ expected_buckets = [
+ (pd.Timestamp(value) + pd.Timedelta(hours=offset)).floor("D")
+ if supports_sql_shift
+ else pd.Timestamp(value).floor("D")
+ + pd.Timedelta(hours=0 if filter_path == "label" else offset)
+ for value in raw_rows
+ ]
+ for _, segment in grouped.df.iterrows():
+ bucket = pd.Timestamp(segment[bucket_label])
+ matching_buckets = [
+ expected_bucket
+ for expected_bucket in expected_buckets
+ if {
+ "==": expected_bucket == bucket,
+ "!=": expected_bucket != bucket,
+ "IN": expected_bucket == bucket,
+ "NOT IN": expected_bucket != bucket,
+ ">": expected_bucket > bucket,
+ "<": expected_bucket < bucket,
+ ">=": expected_bucket >= bucket,
+ "<=": expected_bucket <= bucket,
+ }[operator]
+ ]
+ assert segment["ct"] == expected_buckets.count(bucket)
+ expected_rows = sorted(
+ pd.Timestamp(value)
+ for value, expected_bucket in zip(raw_rows, expected_buckets,
strict=True)
+ if expected_bucket in matching_buckets
+ )
+ filter_columns: dict[str, str | AdhocColumn] = {
+ "physical": "ts",
+ "verbose": "Event time",
+ "timestamp": "__timestamp",
+ "adhoc": axis,
+ "label": "bucket",
+ }
+ filter_column = filter_columns[filter_path]
+ # Adhoc filter values use SQL-formatted dates; physical columns
exercise
+ # epoch coercion into an engine-native timestamp literal.
+ value = (
+ str(bucket)
+ if filter_path in {"adhoc", "label"}
+ else int(bucket.value // 10**6)
+ )
+ query = QueryObject(
+ datasource=table,
+ columns=[axis] if filter_path == "label" else ["ts", "val"],
+ metrics=[
+ {"expressionType": "SQL", "sqlExpression": "COUNT(*)",
"label": "ct"}
+ ]
+ if filter_path == "label"
+ else [],
+ is_timeseries=filter_path == "timestamp",
+ granularity="ts" if filter_path == "timestamp" else None,
+ filters=[
+ {
+ "col": filter_column,
+ "op": operator,
+ "val": [value] if operator in {"IN", "NOT IN"} else value,
+ **({} if filter_path in {"adhoc", "label"} else {"grain":
"P1D"}),
+ }
+ ],
+ )
+ detail = table.get_query_result(query)
+ if filter_path == "label":
+ actual = sorted(
+ zip(
+ pd.to_datetime(detail.df["bucket"]),
+ detail.df["ct"],
+ strict=True,
+ )
+ )
+ expected = sorted(
+ (value, matching_buckets.count(value))
+ for value in dict.fromkeys(matching_buckets)
+ )
+ assert actual == expected
+ else:
+ assert len(detail.df) == len(expected_rows)
+ # A timeseries detail query also normalizes its returned time axis.
+ if filter_path == "timestamp":
+ expected_rows = [
+ value + pd.Timedelta(hours=offset) for value in
expected_rows
+ ]
+ assert sorted(pd.to_datetime(detail.df["ts"]).tolist()) ==
expected_rows
+
+
[email protected](
+ "value,expected",
+ [
+ (1785632400000, 1785628800000),
+ (1785632400000.0, 1785628800000.0),
+ ("1785632400000", "1785628800000"),
+ ("2026-08-02T01:00:00", "2026-08-02 00:00:00"),
+ (datetime(2026, 8, 2, 1), datetime(2026, 8, 2)),
+ ([1785632400000, None], [1785628800000, None]),
+ (None, None),
+ (True, True),
+ ("not a date", "not a date"),
+ ],
+)
+def test_display_only_bucket_offset_value_formats(
+ value: FilterValues | None, expected: FilterValues | None
+) -> None:
+ """Fallback shifts retain value types and preserve invalid or null
values."""
+ assert (
+ ExploreMixin.filter_values_handler(
+ values=value,
+ operator="IN" if isinstance(value, list) else "==",
+ target_generic_type=GenericDataType.TEMPORAL,
+ is_list_target=isinstance(value, list),
+ temporal_offset_hours=1,
+ )
+ == expected
+ )
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["==", "IN", "NOT IN"])
+def test_grained_drill_with_dataset_timezone_ignores_hours_offset(
+ mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+ """Dataset timezone takes precedence over Hours offset on both engine
paths."""
+ mocker.patch.object(
+ SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+ )
+ table, _engine = _sqlite_dataset(
+ mocker, offset, "TIMESTAMP", ["2026-08-01 23:30:00", "2026-08-02
00:30:00"]
+ )
+ table.extra = '{"timezone": "UTC"}'
+ grouped = table.get_query_result(_physical_axis_query(table,
time_grain="P1D"))
+ assert grouped.df["__timestamp"].tolist() == [
+ pd.Timestamp("2026-08-01"),
+ pd.Timestamp("2026-08-02"),
+ ]
+ assert grouped.df["ct"].tolist() == [1, 1]
+ for bucket in grouped.df["__timestamp"]:
+ detail = table.get_query_result(
+ QueryObject(
+ datasource=table,
+ columns=["ts", "val"],
+ metrics=[],
+ is_timeseries=False,
+ filters=[
+ {
+ "col": "ts",
+ "op": operator,
+ "grain": "P1D",
+ "val": [int(bucket.value // 10**6)]
+ if operator in {"IN", "NOT IN"}
+ else int(bucket.value // 10**6),
+ }
+ ],
+ )
+ )
+ assert len(detail.df) == 1
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["IN", "NOT IN"])
+def test_grained_membership_filter_shifts_each_bucket_value(
+ mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+ """Multi-bucket filters invert every displayed operand without losing
NULL."""
+ mocker.patch.object(
+ SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+ )
+ raw_rows = [
+ "2026-08-01 12:30:00",
+ "2026-08-02 12:30:00",
+ "2026-08-03 12:30:00",
+ ]
+ table, engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+ with engine.begin() as connection:
+ connection.exec_driver_sql("INSERT INTO events VALUES (NULL, 1)")
+ grouped = table.get_query_result(_physical_axis_query(table,
time_grain="P1D"))
+ buckets = sorted(pd.to_datetime(grouped.df["__timestamp"]).dropna())
+ assert len(buckets) == 3
+ assert grouped.df["ct"].sum() == 3
+ # Select nonadjacent buckets, so NOT IN must retain the middle bucket.
+ values: FilterValues = [
+ int(buckets[0].value // 10**6),
+ int(buckets[2].value // 10**6),
+ None,
+ ]
+ detail = table.get_query_result(
+ QueryObject(
+ datasource=table,
+ columns=["ts", "val"],
+ metrics=[],
+ is_timeseries=False,
+ filters=[{"col": "ts", "op": operator, "val": values, "grain":
"P1D"}],
+ )
+ )
+ if operator == "IN":
+ assert len(detail.df) == 3
+ assert pd.to_datetime(detail.df["ts"]).isna().sum() == 1
+ expected_rows = [pd.Timestamp(raw_rows[0]), pd.Timestamp(raw_rows[2])]
+ else:
+ assert len(detail.df) == 1
+ assert pd.to_datetime(detail.df["ts"]).isna().sum() == 0
+ expected_rows = [pd.Timestamp(raw_rows[1])]
+ assert sorted(pd.to_datetime(detail.df["ts"]).dropna()) == expected_rows
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["==", "IN", "NOT IN"])
+def test_grained_drill_with_invalid_dataset_timezone_uses_hours_offset(
+ mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+ """An unresolvable timezone falls back to Hours offset in pandas and
filters."""
+ mocker.patch.object(
+ SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+ )
+ raw_rows = ["2026-08-01 23:30:00", "2026-08-02 00:30:00"]
+ table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+ table.extra = '{"timezone": "Not/A_Zone"}'
+ grouped = table.get_query_result(_physical_axis_query(table,
time_grain="P1D"))
+ # The SQL shift needs no configured timezone, so grouping stays unshifted
+ # on both engine groups; pandas then adds the Hours offset to each label.
+ assert grouped.df["__timestamp"].tolist() == [
+ pd.Timestamp("2026-08-01") + pd.Timedelta(hours=offset),
+ pd.Timestamp("2026-08-02") + pd.Timedelta(hours=offset),
+ ]
+ assert grouped.df["ct"].tolist() == [1, 1]
+ for bucket in grouped.df["__timestamp"]:
+ value = int(bucket.value // 10**6)
+ detail = table.get_query_result(
+ QueryObject(
+ datasource=table,
+ columns=["ts", "val"],
+ metrics=[],
+ is_timeseries=False,
+ filters=[
+ {
+ "col": "ts",
+ "op": operator,
+ "grain": "P1D",
+ "val": [value] if operator in {"IN", "NOT IN"} else
value,
+ }
+ ],
+ )
+ )
+ assert len(detail.df) == 1
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [0, 1, -1])
+def test_drill_by_bucket_range_returns_the_segment_rows_with_hours_offset(
+ mocker: MockerFixture, supports_sql_shift: bool, offset: int
+) -> None:
+ """
+ Drill by drops a filter grain, so a grained segment is sent as the
+ TEMPORAL_RANGE of its displayed bucket (the shape the Sunburst and
+ Timeseries Drill by requests carry after convertFilter).
+ """
+ mocker.patch.object(
+ SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+ )
+ raw_rows = [
+ "2026-08-01 23:30:00",
+ "2026-08-02 00:30:00",
+ "2026-08-02 12:00:00",
+ "2026-08-02 23:30:00",
+ ]
+ table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+ grouped = table.get_query_result(_physical_axis_query(table,
time_grain="P1D"))
+ assert grouped.df["ct"].sum() == len(raw_rows)
+ for _, segment in grouped.df.iterrows():
+ since = pd.Timestamp(segment["__timestamp"])
+ until = since + pd.Timedelta(days=1)
+ detail = table.get_query_result(
+ QueryObject(
+ datasource=table,
+ columns=["ts", "val"],
+ metrics=[],
+ is_timeseries=False,
+ filters=[
+ {
+ "col": "ts",
+ "op": "TEMPORAL_RANGE",
+ "val": f"{since:%Y-%m-%dT%H:%M:%S} :
{until:%Y-%m-%dT%H:%M:%S}",
+ }
+ ],
+ )
+ )
+ assert len(detail.df) == segment["ct"]
+
+
[email protected](
+ "value,expected",
+ [
+ (1785628800000, "= 1785628800000"),
+ ("2026-08-02T00:00:00", "= '2026-08-02T00:00:00'"),
+ ],
+)
+def test_adhoc_label_drill_comparator_is_not_coerced(
+ mocker: MockerFixture, app: Flask, value: FilterValues, expected: str
+) -> None:
+ """
+ A filter on an adhoc x-axis label reaches SQL as sent, so Drill by must
send
+ a datetime literal rather than an epoch integer.
+ """
+ table, _engine = _sqlite_dataset(mocker, 0, "TIMESTAMP", rows=[])
+ axis: AdhocColumn = {
+ "sqlExpression": "DATETIME(ts, 'start of day')",
+ "label": "order_day",
+ "columnType": "BASE_AXIS",
+ }
+ query_object = QueryObject(
+ datasource=table,
+ columns=[axis],
+ metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)",
"label": "ct"}],
+ is_timeseries=False,
+ filters=[{"col": "order_day", "op": "==", "val": value}],
+ )
+ with app.test_request_context():
+ sql = table.get_query_str_extended(query_object.to_dict(),
mutate=False).sql
+ assert f"WHERE (DATETIME(ts, 'start of day')) {expected} " in sql