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"


Reply via email to