This is an automated email from the ASF dual-hosted git repository.
rusackas 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 189f258e0ce fix(drill): coerce temporal drill filter values (#40180)
189f258e0ce is described below
commit 189f258e0cec2f8344798574cfa06197f7a3f0fc
Author: Anatolii <[email protected]>
AuthorDate: Sat Jul 11 23:24:51 2026 +0100
fix(drill): coerce temporal drill filter values (#40180)
Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: Superset Dev <[email protected]>
---
.../src/PivotTableChart.tsx | 84 ++++-
.../test/PivotTableChart.test.tsx | 370 +++++++++++++++++++++
.../plugins/plugin-chart-table/src/TableChart.tsx | 28 +-
.../plugin-chart-table/test/TableChart.test.tsx | 49 +++
superset/models/helpers.py | 57 +++-
tests/unit_tests/models/helpers_test.py | 264 ++++++++++++++-
6 files changed, 825 insertions(+), 27 deletions(-)
diff --git
a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
index 56ed2cb5c90..06fc1fa6f66 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx
@@ -28,6 +28,7 @@ import {
BinaryQueryObjectFilterClause,
Currency,
CurrencyFormatter,
+ DataRecord,
DataRecordValue,
FeatureFlag,
getColumnLabel,
@@ -43,6 +44,7 @@ import {
import { styled, useTheme } from '@apache-superset/core/theme';
import { aggregatorTemplates, PivotTable, sortAs } from './react-pivottable';
import {
+ DateFormatter,
FilterType,
MetricsLayoutEnum,
PivotTableProps,
@@ -218,6 +220,46 @@ const aggregatorsFactory = (formatter: NumberFormatter) =>
({
),
});
+const getDrillFilterValue = (
+ value: string,
+ formatter: DateFormatter | undefined,
+): string | number => {
+ if (formatter && value.trim() !== '' && Number.isFinite(Number(value))) {
+ return Number(value);
+ }
+ return value;
+};
+
+const getCrossFilterValue = (
+ value: DataRecordValue,
+ formatter: DateFormatter | undefined,
+): DataRecordValue => {
+ if (
+ formatter &&
+ typeof value === 'string' &&
+ value.trim() !== '' &&
+ Number.isFinite(Number(value))
+ ) {
+ return Number(value);
+ }
+ return value;
+};
+
+const getDrillFilterFormattedValue = (
+ value: string,
+ formatter: DateFormatter | undefined,
+): string => {
+ const valueToFormat: DataRecordValue =
+ value.trim() !== '' && Number.isFinite(Number(value))
+ ? Number(value)
+ : value;
+ return (
+ (formatter as ((value: DataRecordValue) => string) | undefined)?.(
+ valueToFormat,
+ ) || String(value)
+ );
+};
+
/* If you change this logic, please update the corresponding Python
* function
(https://github.com/apache/superset/blob/master/superset/charts/post_processing.py),
* or reach out to @betodealmeida.
@@ -342,7 +384,7 @@ export default function PivotTableChart(props:
PivotTableProps) {
const unpivotedData = useMemo(
() =>
data.reduce(
- (acc: Record<string, any>[], record: Record<string, any>) => [
+ (acc: DataRecord[], record: DataRecord) => [
...acc,
...metricNames
.map((name: string) => ({
@@ -419,10 +461,16 @@ export default function PivotTableChart(props:
PivotTableProps) {
col,
op: 'IS NULL',
};
+ // Resolve the formatter by the header key/label so adhoc
+ // temporal groupby columns (where `col` is an object, not a
+ // string) still get epoch coercion, matching physical
columns.
+ const formatter = dateFormatters[key];
return {
col,
op: 'IN',
- val: val as (string | number | boolean)[],
+ val: (val as DataRecordValue[]).map(value =>
+ getCrossFilterValue(value, formatter),
+ ) as (string | number | boolean)[],
};
}),
},
@@ -436,7 +484,7 @@ export default function PivotTableChart(props:
PivotTableProps) {
},
});
},
- [groupbyColumnsRaw, groupbyRowsRaw, setDataMask],
+ [dateFormatters, groupbyColumnsRaw, groupbyRowsRaw, setDataMask],
);
const isActiveFilterValue = useCallback(
@@ -492,10 +540,17 @@ export default function PivotTableChart(props:
PivotTableProps) {
col,
op: 'IS NULL' as const,
};
+ // Resolve the formatter by the header key/label so adhoc
+ // temporal groupby columns (where `col` is an object, not
a
+ // string) still get epoch coercion, matching physical
+ // columns.
+ const formatter = dateFormatters[key];
return {
col,
op: 'IN' as const,
- val: val as (string | number | boolean)[],
+ val: (val as DataRecordValue[]).map(value =>
+ getCrossFilterValue(value, formatter),
+ ) as (string | number | boolean)[],
};
}),
},
@@ -511,7 +566,13 @@ export default function PivotTableChart(props:
PivotTableProps) {
isCurrentValueSelected: isActiveFilterValue(key, val),
};
},
- [groupbyColumnsRaw, groupbyRowsRaw, isActiveFilterValue, selectedFilters],
+ [
+ dateFormatters,
+ groupbyColumnsRaw,
+ groupbyRowsRaw,
+ isActiveFilterValue,
+ selectedFilters,
+ ],
);
const toggleFilter = useCallback(
@@ -535,7 +596,10 @@ export default function PivotTableChart(props:
PivotTableProps) {
const filtersCopy = { ...filters };
delete filtersCopy[METRIC_KEY];
- const filtersEntries = Object.entries(filtersCopy);
+ const filtersEntries = Object.entries(filtersCopy) as [
+ string,
+ DataRecordValue,
+ ][];
if (filtersEntries.length === 0) {
return;
}
@@ -637,12 +701,12 @@ export default function PivotTableChart(props:
PivotTableProps) {
colKey.forEach((val, i) => {
const col = cols[i];
const formatter = dateFormatters[col];
- const formattedVal = formatter?.(Number(val)) || String(val);
+ const formattedVal = getDrillFilterFormattedValue(val, formatter);
if (i > 0) {
drillToDetailFilters.push({
col,
op: '==',
- val,
+ val: getDrillFilterValue(val, formatter),
formattedVal,
grain: formatter ? timeGrainSqla : undefined,
});
@@ -653,11 +717,11 @@ export default function PivotTableChart(props:
PivotTableProps) {
rowKey.forEach((val, i) => {
const col = rows[i];
const formatter = dateFormatters[col];
- const formattedVal = formatter?.(Number(val)) || String(val);
+ const formattedVal = getDrillFilterFormattedValue(val, formatter);
drillToDetailFilters.push({
col,
op: '==',
- val,
+ val: getDrillFilterValue(val, formatter),
formattedVal,
grain: formatter ? timeGrainSqla : undefined,
});
diff --git
a/superset-frontend/plugins/plugin-chart-pivot-table/test/PivotTableChart.test.tsx
b/superset-frontend/plugins/plugin-chart-pivot-table/test/PivotTableChart.test.tsx
new file mode 100644
index 00000000000..1e9365d4d15
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-pivot-table/test/PivotTableChart.test.tsx
@@ -0,0 +1,370 @@
+/**
+ * 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 type { ReactElement } from 'react';
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
+import {
+ TimeGranularity,
+ type DataRecordValue,
+ type QueryFormColumn,
+} from '@superset-ui/core';
+import PivotTableChart from '../src/PivotTableChart';
+import { MetricsLayoutEnum, type PivotTableProps } from '../src/types';
+
+function renderWithTheme(ui: ReactElement) {
+ return render(<ThemeProvider theme={supersetTheme}>{ui}</ThemeProvider>);
+}
+
+function createProps(
+ overrides: Partial<PivotTableProps> = {},
+): PivotTableProps {
+ return {
+ data: [],
+ height: 400,
+ width: 600,
+ margin: 0,
+ groupbyRows: [],
+ groupbyColumns: [],
+ metrics: ['value'],
+ tableRenderer: 'Table',
+ colOrder: 'key_a_to_z',
+ rowOrder: 'key_a_to_z',
+ aggregateFunction: 'Count',
+ transposePivot: false,
+ combineMetric: false,
+ rowSubtotalPosition: false,
+ colSubtotalPosition: false,
+ colTotals: false,
+ colSubTotals: false,
+ rowTotals: false,
+ rowSubTotals: false,
+ valueFormat: 'SMART_NUMBER',
+ currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
+ setDataMask: jest.fn(),
+ emitCrossFilters: true,
+ selectedFilters: {},
+ verboseMap: {},
+ columnFormats: {},
+ currencyFormats: {},
+ metricsLayout: MetricsLayoutEnum.COLUMNS,
+ metricColorFormatters: [],
+ dateFormatters: {},
+ legacy_order_by: null,
+ order_desc: false,
+ timeGrainSqla: TimeGranularity.DAY,
+ allowRenderHtml: false,
+ ...overrides,
+ };
+}
+
+test('emits numeric temporal values for drill-to-detail filters on formatted
row headers', () => {
+ const onContextMenu = jest.fn();
+ const timestamp = 1778630400000;
+ const props: PivotTableProps = {
+ data: [{ install_date: String(timestamp), value: 1 }],
+ height: 400,
+ width: 600,
+ margin: 0,
+ groupbyRows: ['install_date'],
+ groupbyColumns: [],
+ metrics: ['value'],
+ tableRenderer: 'Table',
+ colOrder: 'key_a_to_z',
+ rowOrder: 'key_a_to_z',
+ aggregateFunction: 'Count',
+ transposePivot: false,
+ combineMetric: false,
+ rowSubtotalPosition: false,
+ colSubtotalPosition: false,
+ colTotals: false,
+ colSubTotals: false,
+ rowTotals: false,
+ rowSubTotals: false,
+ valueFormat: 'SMART_NUMBER',
+ currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
+ setDataMask: jest.fn(),
+ emitCrossFilters: true,
+ selectedFilters: {},
+ verboseMap: {},
+ columnFormats: {},
+ currencyFormats: {},
+ metricsLayout: MetricsLayoutEnum.COLUMNS,
+ metricColorFormatters: [],
+ dateFormatters: {
+ install_date: (value: DataRecordValue) =>
+ new Date(Number(value)).toISOString().slice(0, 10),
+ },
+ legacy_order_by: null,
+ order_desc: false,
+ onContextMenu,
+ timeGrainSqla: TimeGranularity.DAY,
+ allowRenderHtml: false,
+ };
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const rowHeader = screen.getByText('2026-05-13').closest('th');
+ expect(rowHeader).not.toBeNull();
+ fireEvent.contextMenu(rowHeader!);
+
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
+ const contextMenuFilters = onContextMenu.mock.calls[0][2];
+ expect(contextMenuFilters?.drillToDetail).toEqual([
+ {
+ col: 'install_date',
+ op: '==',
+ val: timestamp,
+ formattedVal: '2026-05-13',
+ grain: TimeGranularity.DAY,
+ },
+ ]);
+});
+
+test('keeps non-numeric temporal values for drill-to-detail formatted labels',
() => {
+ const onContextMenu = jest.fn();
+ const dateValue = '2024-01-01';
+ const props: PivotTableProps = {
+ data: [{ install_date: dateValue, value: 1 }],
+ height: 400,
+ width: 600,
+ margin: 0,
+ groupbyRows: ['install_date'],
+ groupbyColumns: [],
+ metrics: ['value'],
+ tableRenderer: 'Table',
+ colOrder: 'key_a_to_z',
+ rowOrder: 'key_a_to_z',
+ aggregateFunction: 'Count',
+ transposePivot: false,
+ combineMetric: false,
+ rowSubtotalPosition: false,
+ colSubtotalPosition: false,
+ colTotals: false,
+ colSubTotals: false,
+ rowTotals: false,
+ rowSubTotals: false,
+ valueFormat: 'SMART_NUMBER',
+ currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
+ setDataMask: jest.fn(),
+ emitCrossFilters: true,
+ selectedFilters: {},
+ verboseMap: {},
+ columnFormats: {},
+ currencyFormats: {},
+ metricsLayout: MetricsLayoutEnum.COLUMNS,
+ metricColorFormatters: [],
+ dateFormatters: {
+ install_date: (value: DataRecordValue) => String(value),
+ },
+ legacy_order_by: null,
+ order_desc: false,
+ onContextMenu,
+ timeGrainSqla: TimeGranularity.DAY,
+ allowRenderHtml: false,
+ };
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const rowHeader = screen.getByText(dateValue).closest('th');
+ expect(rowHeader).not.toBeNull();
+ fireEvent.contextMenu(rowHeader!);
+
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
+ const contextMenuFilters = onContextMenu.mock.calls[0][2];
+ expect(contextMenuFilters?.drillToDetail).toEqual([
+ {
+ col: 'install_date',
+ op: '==',
+ val: dateValue,
+ formattedVal: dateValue,
+ grain: TimeGranularity.DAY,
+ },
+ ]);
+});
+
+test('keeps non-formatted drill-to-detail values as strings', () => {
+ const onContextMenu = jest.fn();
+ const props = createProps({
+ data: [{ country: 'US', value: 1 }],
+ groupbyRows: ['country'],
+ onContextMenu,
+ });
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const rowHeader = screen.getByText('US').closest('th');
+ expect(rowHeader).not.toBeNull();
+ fireEvent.contextMenu(rowHeader!);
+
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
+ const contextMenuFilters = onContextMenu.mock.calls[0][2];
+ expect(contextMenuFilters?.drillToDetail).toEqual([
+ {
+ col: 'country',
+ op: '==',
+ val: 'US',
+ formattedVal: 'US',
+ grain: undefined,
+ },
+ ]);
+});
+
+test('emits numeric temporal values for cross-filters on formatted row
headers', () => {
+ const setDataMask = jest.fn();
+ const timestamp = 1777248000000;
+ const props: PivotTableProps = {
+ data: [{ install_date: String(timestamp), value: 1 }],
+ height: 400,
+ width: 600,
+ margin: 0,
+ groupbyRows: ['install_date'],
+ groupbyColumns: [],
+ metrics: ['value'],
+ tableRenderer: 'Table',
+ colOrder: 'key_a_to_z',
+ rowOrder: 'key_a_to_z',
+ aggregateFunction: 'Count',
+ transposePivot: false,
+ combineMetric: false,
+ rowSubtotalPosition: false,
+ colSubtotalPosition: false,
+ colTotals: false,
+ colSubTotals: false,
+ rowTotals: false,
+ rowSubTotals: false,
+ valueFormat: 'SMART_NUMBER',
+ currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
+ setDataMask,
+ emitCrossFilters: true,
+ selectedFilters: {},
+ verboseMap: {},
+ columnFormats: {},
+ currencyFormats: {},
+ metricsLayout: MetricsLayoutEnum.COLUMNS,
+ metricColorFormatters: [],
+ dateFormatters: {
+ install_date: (value: DataRecordValue) =>
+ new Date(Number(value)).toISOString().slice(0, 10),
+ },
+ legacy_order_by: null,
+ order_desc: false,
+ timeGrainSqla: TimeGranularity.DAY,
+ allowRenderHtml: false,
+ };
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const rowHeader = screen.getByText('2026-04-27').closest('th');
+ expect(rowHeader).not.toBeNull();
+ fireEvent.click(rowHeader!);
+
+ expect(setDataMask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ {
+ col: 'install_date',
+ op: 'IN',
+ val: [timestamp],
+ },
+ ],
+ },
+ }),
+ );
+});
+
+test('keeps non-numeric temporal values for cross-filters on formatted row
headers', () => {
+ const setDataMask = jest.fn();
+ const dateValue = '2024-01-01';
+ const props = createProps({
+ data: [{ install_date: dateValue, value: 1 }],
+ groupbyRows: ['install_date'],
+ setDataMask,
+ dateFormatters: {
+ install_date: (value: DataRecordValue) => String(value),
+ },
+ });
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const rowHeader = screen.getByText(dateValue).closest('th');
+ expect(rowHeader).not.toBeNull();
+ fireEvent.click(rowHeader!);
+
+ expect(setDataMask).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ {
+ col: 'install_date',
+ op: 'IN',
+ val: [dateValue],
+ },
+ ],
+ },
+ }),
+ );
+});
+
+test('emits drill filters from formatted column headers', () => {
+ const onContextMenu = jest.fn();
+ const timestamp = 1778630400000;
+ const adhocColumn: QueryFormColumn = {
+ label: 'Install date expression',
+ sqlExpression: 'install_date',
+ expressionType: 'SQL',
+ };
+ const props = createProps({
+ data: [{ 'Install date expression': String(timestamp), value: 1 }],
+ groupbyColumns: [adhocColumn],
+ dateFormatters: {
+ 'Install date expression': (value: DataRecordValue) =>
+ new Date(Number(value)).toISOString().slice(0, 10),
+ },
+ onContextMenu,
+ });
+
+ renderWithTheme(<PivotTableChart {...props} />);
+
+ const columnHeader = screen.getByText('2026-05-13').closest('th');
+ expect(columnHeader).not.toBeNull();
+ fireEvent.contextMenu(columnHeader!);
+
+ expect(onContextMenu).toHaveBeenCalledTimes(1);
+ const contextMenuFilters = onContextMenu.mock.calls[0][2];
+ expect(contextMenuFilters?.drillToDetail).toEqual([
+ {
+ col: 'Install date expression',
+ op: '==',
+ val: timestamp,
+ formattedVal: '2026-05-13',
+ grain: TimeGranularity.DAY,
+ },
+ ]);
+ expect(contextMenuFilters?.crossFilter?.dataMask.extraFormData).toEqual({
+ filters: [
+ {
+ col: adhocColumn,
+ op: 'IN',
+ val: [timestamp],
+ },
+ ],
+ });
+});
diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
index be0fe1c5f9f..43293e54c79 100644
--- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
@@ -108,6 +108,25 @@ interface TableSize {
height: number;
}
+const getCrossFilterValue = (
+ value: DataRecordValue,
+ column: DataColumnMeta | undefined,
+): DataRecordValue => {
+ const input = value instanceof DateWithFormatter ? value.input : value;
+ if (
+ column?.dataType === GenericDataType.Temporal &&
+ typeof input === 'string' &&
+ input.trim() !== '' &&
+ Number.isFinite(Number(input))
+ ) {
+ return Number(input);
+ }
+ if (value instanceof Date) {
+ return value.getTime();
+ }
+ return value;
+};
+
const ACTION_KEYS = {
enter: 'Enter',
spacebar: 'Spacebar',
@@ -533,6 +552,9 @@ export default function TableChart<D extends DataRecord =
DataRecord>(
// so that cross-filters work on the receiving chart
const resolvedCol = columnLabelToNameMap[col] ?? col;
const val = ensureIsArray(updatedFilters?.[col]);
+ const column = columnsMeta.find(
+ columnMeta => columnMeta.key === col,
+ );
if (
!val.length ||
val[0] === null ||
@@ -546,9 +568,7 @@ export default function TableChart<D extends DataRecord =
DataRecord>(
return {
col: resolvedCol,
op: 'IN' as const,
- val: val.map(el =>
- el instanceof Date ? el.getTime() : el!,
- ),
+ val: val.map(el => getCrossFilterValue(el!, column)),
grain: resolvedCol === DTTM_ALIAS ? timeGrain :
undefined,
};
}),
@@ -571,6 +591,7 @@ export default function TableChart<D extends DataRecord =
DataRecord>(
timestampFormatter,
timeGrain,
columnLabelToNameMap,
+ columnsMeta,
],
);
@@ -1317,7 +1338,6 @@ export default function TableChart<D extends DataRecord =
DataRecord>(
col.toggleSortBy();
}
}}
- role="columnheader button"
onClick={onClick}
data-column-name={col.id}
{...(allowRearrangeColumns && {
diff --git
a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
index ddfc27e5957..06a05fe5758 100644
--- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx
@@ -31,6 +31,7 @@ import {
} from '@superset-ui/core/spec';
import { cloneDeep } from 'lodash-es';
import {
+ type DataMask,
QueryMode,
TimeGranularity,
SMART_DATE_ID,
@@ -1843,6 +1844,54 @@ describe('plugin-chart-table', () => {
expect(secondCallArg.extraFormData.filters).toEqual([]);
});
+ test('clicking a temporal numeric-string cell emits numeric cross-filter
values', () => {
+ const setDataMask = jest.fn<void, [DataMask]>();
+ const timestamp = 1777248000000;
+ const props = transformProps({
+ ...testData.basic,
+ hooks: { setDataMask },
+ emitCrossFilters: true,
+ });
+
+ render(
+ <ProviderWrapper>
+ <TableChart
+ {...props}
+ data={[{ install_date: String(timestamp) }]}
+ columns={[
+ {
+ key: 'install_date',
+ label: 'install_date',
+ dataType: GenericDataType.Temporal,
+ isNumeric: false,
+ isMetric: false,
+ isPercentMetric: false,
+ formatter: String,
+ config: {},
+ },
+ ]}
+ emitCrossFilters
+ setDataMask={setDataMask}
+ sticky={false}
+ />
+ </ProviderWrapper>,
+ );
+
+ fireEvent.click(screen.getByText(String(timestamp)));
+
+ const crossFilterCall = setDataMask.mock.calls.find(
+ call => call[0].filterState?.filters,
+ );
+ expect(crossFilterCall).toBeDefined();
+ expect(crossFilterCall?.[0].extraFormData?.filters).toEqual([
+ {
+ col: 'install_date',
+ op: 'IN',
+ val: [timestamp],
+ },
+ ]);
+ });
+
test('cross-filter toggle works with DateWithFormatter values', () => {
const setDataMask = jest.fn();
const props = transformProps({
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 20cac92e863..8fcff4c7ee4 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -2710,21 +2710,50 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
if values is None:
return None
- def handle_single_value(value: Optional[FilterValue]) ->
Optional[FilterValue]:
- if operator == utils.FilterOperator.TEMPORAL_RANGE:
- return value
+ temporal_comparison_operators: set[utils.FilterOperator] = {
+ 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,
+ }
+
+ def handle_temporal_value(value: FilterValue) -> FilterValue |
ColumnElement:
if (
- isinstance(value, (float, int))
- and target_generic_type == utils.GenericDataType.TEMPORAL
- and target_native_type is not None
- and db_engine_spec is not None
+ operator not in temporal_comparison_operators
+ or target_generic_type != utils.GenericDataType.TEMPORAL
+ or target_native_type is None
+ or db_engine_spec is None
):
- value = db_engine_spec.convert_dttm(
- target_type=target_native_type,
- dttm=datetime.utcfromtimestamp(value / 1000),
- db_extra=db_extra,
+ return value
+
+ if isinstance(value, (float, int)) and not isinstance(value, bool):
+ epoch_ms: float = value
+ elif isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value):
+ epoch_ms = int(value)
+ else:
+ return value
+
+ try:
+ dttm = datetime.fromtimestamp(epoch_ms / 1000,
tz=timezone.utc).replace(
+ tzinfo=None
)
- value = literal_column(value)
+ except (OverflowError, OSError, ValueError):
+ return value
+
+ temporal_sql = db_engine_spec.convert_dttm(
+ target_type=target_native_type,
+ dttm=dttm,
+ db_extra=db_extra,
+ )
+ return literal_column(temporal_sql) if temporal_sql is not None
else value
+
+ def handle_single_value(value: Optional[FilterValue]) ->
Optional[FilterValue]:
+ if operator == utils.FilterOperator.TEMPORAL_RANGE:
+ return value
if isinstance(value, str):
value = value.strip("\t\n")
@@ -2745,6 +2774,9 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
return None
if value == EMPTY_STRING:
return ""
+ value = handle_temporal_value(value)
+ elif value is not None:
+ value = handle_temporal_value(value)
if target_generic_type == utils.GenericDataType.BOOLEAN:
return utils.cast_to_boolean(value)
return value
@@ -3735,6 +3767,7 @@ class ExploreMixin: # pylint:
disable=too-many-public-methods
target_native_type=col_type,
is_list_target=is_list_target,
db_engine_spec=db_engine_spec,
+ db_extra=self.db_extra,
)
# Get ADVANCED_DATA_TYPES from config when needed
diff --git a/tests/unit_tests/models/helpers_test.py
b/tests/unit_tests/models/helpers_test.py
index 9603e4cd0f5..2e3736f18d5 100644
--- a/tests/unit_tests/models/helpers_test.py
+++ b/tests/unit_tests/models/helpers_test.py
@@ -32,7 +32,7 @@ from sqlalchemy.pool import StaticPool
from sqlalchemy.sql.elements import ColumnElement
from superset.superset_typing import AdhocColumn, AdhocMetric, OrderBy
-from superset.utils.core import GenericDataType
+from superset.utils.core import FilterOperator, GenericDataType
from tests.unit_tests.conftest import with_feature_flags
if TYPE_CHECKING:
@@ -3410,3 +3410,265 @@ def test_get_sqla_query_dotted_struct_column_bigquery(
# ```forecasts.original`.`total_cost``` (the regression), so this negative
# assertion catches the actual failure mode, not just an exact-string
match.
assert "`forecasts.original`" not in sql
+
+
+def test_temporal_epoch_string_filter_is_coerced_for_bigquery() -> None:
+ """
+ Drill-to-detail can send JavaScript timestamp strings for temporal values.
+ BigQuery DATE filters must receive a DATE literal instead of the raw
string.
+ """
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="1778630400000",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert isinstance(value, ColumnElement)
+ assert str(value) == "CAST('2026-05-13' AS DATE)"
+
+
+def test_temporal_negative_epoch_string_filter_is_coerced_for_bigquery() ->
None:
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="-1778630400000",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert isinstance(value, ColumnElement)
+ assert str(value) == "CAST('1913-08-22' AS DATE)"
+
+
[email protected]("value", [1778630400000, 1778630400000.0])
+def test_temporal_numeric_filter_is_coerced_for_bigquery(
+ value: int | float,
+) -> None:
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ result = ExploreMixin.filter_values_handler(
+ values=value,
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert isinstance(result, ColumnElement)
+ assert str(result) == "CAST('2026-05-13' AS DATE)"
+
+
+def test_temporal_overflow_epoch_filter_is_not_coerced() -> None:
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = "999999999999999999999999999999999999999"
+
+ result = ExploreMixin.filter_values_handler(
+ values=value,
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert result == value
+
+
+def test_temporal_epoch_filter_is_not_coerced_without_engine_literal() -> None:
+ from superset.db_engine_specs.base import BaseEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ result = ExploreMixin.filter_values_handler(
+ values="1778630400000",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="UNKNOWN_TYPE",
+ db_engine_spec=BaseEngineSpec,
+ )
+
+ assert result == "1778630400000"
+
+
[email protected](
+ "operator",
+ [FilterOperator.IN, FilterOperator.NOT_IN],
+)
+def test_temporal_epoch_string_filter_list_is_coerced_for_bigquery(
+ operator: FilterOperator,
+) -> None:
+ """
+ Cross-filtering can send JavaScript timestamp strings inside IN lists.
+ BigQuery DATE filters must receive DATE literals instead of raw strings.
+ """
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ values = ExploreMixin.filter_values_handler(
+ values=["1777248000000"],
+ operator=operator,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ is_list_target=True,
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert isinstance(values, list)
+ assert len(values) == 1
+ assert isinstance(values[0], ColumnElement)
+ assert str(values[0]) == "CAST('2026-04-27' AS DATE)"
+
+
+def test_temporal_epoch_string_filter_list_is_coerced_for_clickhouse() -> None:
+ from superset.db_engine_specs.clickhouse import ClickHouseEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ values = ExploreMixin.filter_values_handler(
+ values=["1777248000000"],
+ operator=FilterOperator.IN,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="Date",
+ is_list_target=True,
+ db_engine_spec=ClickHouseEngineSpec,
+ )
+
+ assert isinstance(values, list)
+ assert len(values) == 1
+ assert isinstance(values[0], ColumnElement)
+ assert str(values[0]) == "toDate('2026-04-27')"
+
+
[email protected](
+ "target_type,expected",
+ [
+ ("DATE", "TO_DATE('2026-05-13', 'YYYY-MM-DD')"),
+ (
+ "TIMESTAMP",
+ "TO_TIMESTAMP('2026-05-13 00:00:00.000000', 'YYYY-MM-DD
HH24:MI:SS.US')",
+ ),
+ ],
+)
+def test_temporal_epoch_string_filter_is_coerced_for_postgres(
+ target_type: str,
+ expected: str,
+) -> None:
+ from superset.db_engine_specs.postgres import PostgresEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="1778630400000",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type=target_type,
+ db_engine_spec=PostgresEngineSpec,
+ )
+
+ assert isinstance(value, ColumnElement)
+ assert str(value) == expected
+
+
+def test_temporal_epoch_string_filter_list_is_coerced_for_postgres() -> None:
+ from superset.db_engine_specs.postgres import PostgresEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ values = ExploreMixin.filter_values_handler(
+ values=["1777248000000"],
+ operator=FilterOperator.IN,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ is_list_target=True,
+ db_engine_spec=PostgresEngineSpec,
+ )
+
+ assert isinstance(values, list)
+ assert len(values) == 1
+ assert isinstance(values[0], ColumnElement)
+ assert str(values[0]) == "TO_DATE('2026-04-27', 'YYYY-MM-DD')"
+
+
+def test_non_temporal_numeric_string_filter_is_not_coerced() -> None:
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="1778630400000",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.STRING,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert value == "1778630400000"
+
+
+def test_temporal_range_filter_value_is_not_coerced() -> None:
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="No filter",
+ operator=FilterOperator.TEMPORAL_RANGE,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert value == "No filter"
+
+
[email protected](
+ "target_type,expected",
+ [
+ (
+ "DATETIME",
+ "CAST('2026-05-13T14:30:00.123000' AS DATETIME)",
+ ),
+ (
+ "TIMESTAMP",
+ "CAST('2026-05-13T14:30:00.123000' AS TIMESTAMP)",
+ ),
+ ],
+)
+def test_temporal_epoch_string_filter_is_coerced_for_datetime_bigquery(
+ target_type: str,
+ expected: str,
+) -> None:
+ """Sub-second epoch precision survives DATETIME / TIMESTAMP coercion."""
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="1778682600123",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type=target_type,
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert isinstance(value, ColumnElement)
+ assert str(value) == expected
+
+
+def test_temporal_non_numeric_string_filter_is_not_coerced() -> None:
+ """Non-integer temporal strings pass through without epoch coercion."""
+ from superset.db_engine_specs.bigquery import BigQueryEngineSpec
+ from superset.models.helpers import ExploreMixin
+
+ value = ExploreMixin.filter_values_handler(
+ values="2025-12-20",
+ operator=FilterOperator.EQUALS,
+ target_generic_type=GenericDataType.TEMPORAL,
+ target_native_type="DATE",
+ db_engine_spec=BigQueryEngineSpec,
+ )
+
+ assert value == "2025-12-20"