This is an automated email from the ASF dual-hosted git repository.

justinpark 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 cb319c9b817 fix(mcp): trust dataset is_dttm flag when applying 
time_grain to VARCHAR temporal columns (#42288)
cb319c9b817 is described below

commit cb319c9b8179668a46781eca55628311470b04a5
Author: JUST.in DO IT <[email protected]>
AuthorDate: Thu Jul 23 10:00:22 2026 -0700

    fix(mcp): trust dataset is_dttm flag when applying time_grain to VARCHAR 
temporal columns (#42288)
---
 superset/mcp_service/chart/chart_utils.py          | 87 +++++++++++++---------
 .../mcp_service/chart/test_chart_utils.py          | 83 +++++++++++++++++++++
 2 files changed, 135 insertions(+), 35 deletions(-)

diff --git a/superset/mcp_service/chart/chart_utils.py 
b/superset/mcp_service/chart/chart_utils.py
index fbd59b03c2f..ea8079ea1e9 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -289,20 +289,59 @@ def _find_dataset_by_id_or_uuid(dataset_id: int | str | 
None) -> "SqlaTable | No
     return DatasetDAO.find_by_id_or_uuid(str(dataset_id))
 
 
+def _is_dataset_column_temporal(
+    col: Any, column_name: str, db_engine_spec: Any
+) -> bool:
+    """Decide temporality for a single dataset column, mirroring
+    TableColumn.is_temporal: native temporal SQL types are always
+    temporal, and is_dttm=True is otherwise trusted over the raw SQL
+    type -- this is the standard, supported way to mark a non-temporal
+    column (e.g. a VARCHAR "ds" partition column on Hive/Presto/Trino)
+    as a date.
+
+    The one case guarded against is a plain NUMERIC column (e.g. an
+    integer "year"/"month" column) that Superset's column-name
+    heuristics may have mis-flagged as is_dttm=True with no
+    python_date_format to parse it -- applying DATE_TRUNC/time_grain to
+    that would fail at query time.
+    """
+    from superset.utils.core import GenericDataType
+
+    is_dttm = bool(getattr(col, "is_dttm", False))
+    col_type = col.type
+    if not col_type:
+        return is_dttm  # No type info, trust is_dttm flag
+
+    column_spec = db_engine_spec.get_column_spec(col_type)
+    generic_type = column_spec.generic_type if column_spec else None
+
+    if generic_type == GenericDataType.TEMPORAL:
+        return True
+    if not is_dttm:
+        return False
+    if generic_type != GenericDataType.NUMERIC or getattr(
+        col, "python_date_format", None
+    ):
+        return True
+
+    logger.debug(
+        "Column '%s' is marked is_dttm=True but has numeric type '%s' with "
+        "no python_date_format; treating as non-temporal to avoid an "
+        "invalid DATE_TRUNC on a numeric column",
+        column_name,
+        col_type,
+    )
+    return False
+
+
 def is_column_truly_temporal(
     column_name: str,
     dataset_id: int | str | None,
     dataset: "SqlaTable | None" = None,
 ) -> bool:
     """
-    Check if a column is truly temporal based on its SQL data type.
-
-    This is important because Superset may mark columns as is_dttm=True based 
on
-    column name heuristics (e.g., "year", "month"), but if the actual SQL type 
is
-    BIGINT or INTEGER, DATE_TRUNC will fail.
-
-    Uses the database engine spec's column type mapping to determine the actual
-    GenericDataType, bypassing the is_dttm flag which may be set incorrectly.
+    Check if a column is truly temporal, mirroring TableColumn.is_temporal
+    (see ``_is_dataset_column_temporal`` for the precedence rules).
 
     Args:
         column_name: Name of the column to check
@@ -311,9 +350,8 @@ def is_column_truly_temporal(
             redundant DAO lookup when the caller already resolved it.
 
     Returns:
-        True if the column has a real temporal SQL type, False otherwise
+        True if the column should be treated as temporal, False otherwise
     """
-    from superset.utils.core import GenericDataType
 
     if not dataset_id and dataset is None:
         return True  # Default to temporal if we can't check (backward 
compatible)
@@ -325,34 +363,11 @@ def is_column_truly_temporal(
         if not dataset:
             return True  # Default to temporal if dataset not found
 
-        # Find the column and check its actual type using db_engine_spec
         column_lower = column_name.lower()
         for col in dataset.columns:
             if col.column_name.lower() == column_lower:
-                col_type = col.type
-                if not col_type:
-                    # No type info, trust is_dttm flag
-                    return getattr(col, "is_dttm", False)
-
-                # Use the db_engine_spec to get the actual GenericDataType
-                # This bypasses the is_dttm flag and checks the real SQL type
                 db_engine_spec = dataset.database.db_engine_spec
-                column_spec = db_engine_spec.get_column_spec(col_type)
-
-                if column_spec:
-                    is_temporal = column_spec.generic_type == 
GenericDataType.TEMPORAL
-                    if not is_temporal:
-                        logger.debug(
-                            "Column '%s' has type '%s' (generic: %s), "
-                            "treating as non-temporal",
-                            column_name,
-                            col_type,
-                            column_spec.generic_type,
-                        )
-                    return is_temporal
-
-                # If no column_spec, trust is_dttm flag
-                return getattr(col, "is_dttm", False)
+                return _is_dataset_column_temporal(col, column_name, 
db_engine_spec)
 
         return True  # Default if column not found
 
@@ -707,7 +722,9 @@ def configure_temporal_handling(
             form_data.setdefault("_mcp_warnings", []).append(
                 f"time_grain='{time_grain}' was ignored because the x-axis "
                 f"column is not a temporal type. time_grain only applies to "
-                f"DATE/DATETIME/TIMESTAMP columns."
+                f"DATE/DATETIME/TIMESTAMP columns, or other column types "
+                f"explicitly marked as temporal (is_dttm) with a "
+                f"python_date_format on the dataset."
             )
 
 
diff --git a/tests/unit_tests/mcp_service/chart/test_chart_utils.py 
b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
index 473a88a76eb..256ee7032c4 100644
--- a/tests/unit_tests/mcp_service/chart/test_chart_utils.py
+++ b/tests/unit_tests/mcp_service/chart/test_chart_utils.py
@@ -1421,11 +1421,15 @@ class TestIsColumnTrulyTemporal:
         column_name: str,
         column_type: str,
         generic_type: GenericDataType,
+        is_dttm: bool = False,
+        python_date_format: str | None = None,
     ):
         """Helper to create a mock dataset with proper db_engine_spec"""
         mock_column = MagicMock()
         mock_column.column_name = column_name
         mock_column.type = column_type
+        mock_column.is_dttm = is_dttm
+        mock_column.python_date_format = python_date_format
 
         mock_db_engine_spec = MagicMock()
         mock_column_spec = ColumnSpec(
@@ -1576,6 +1580,85 @@ class TestIsColumnTrulyTemporal:
         result = is_column_truly_temporal("year", 123)
         assert result is True
 
+    @patch("superset.daos.dataset.DatasetDAO")
+    def test_returns_true_for_varchar_ds_column_with_is_dttm_and_date_format(
+        self, mock_dao
+    ) -> None:
+        """VARCHAR 'ds' column explicitly marked is_dttm with a date format
+        (e.g. Hive/Presto string partition column) should be treated as
+        temporal, mirroring TableColumn.is_temporal in Superset core."""
+        mock_dataset = self._create_mock_dataset(
+            "ds",
+            "VARCHAR",
+            GenericDataType.STRING,
+            is_dttm=True,
+            python_date_format="%Y-%m-%d",
+        )
+        mock_dao.find_by_id_or_uuid.return_value = mock_dataset
+
+        result = is_column_truly_temporal("ds", 123)
+        assert result is True
+
+    @patch("superset.daos.dataset.DatasetDAO")
+    def test_returns_true_for_varchar_ds_column_with_is_dttm_no_date_format(
+        self, mock_dao
+    ) -> None:
+        """VARCHAR column marked is_dttm without a python_date_format is
+        still trusted as temporal -- only NUMERIC columns require a date
+        format before being trusted (guards against name-based
+        misdetection like a plain integer 'year' column)."""
+        mock_dataset = self._create_mock_dataset(
+            "ds", "VARCHAR", GenericDataType.STRING, is_dttm=True
+        )
+        mock_dao.find_by_id_or_uuid.return_value = mock_dataset
+
+        result = is_column_truly_temporal("ds", 123)
+        assert result is True
+
+    @patch("superset.daos.dataset.DatasetDAO")
+    def test_returns_false_for_varchar_column_without_is_dttm(self, mock_dao) 
-> None:
+        """A plain VARCHAR column not marked temporal stays non-temporal."""
+        mock_dataset = self._create_mock_dataset(
+            "description", "VARCHAR", GenericDataType.STRING, is_dttm=False
+        )
+        mock_dao.find_by_id_or_uuid.return_value = mock_dataset
+
+        result = is_column_truly_temporal("description", 123)
+        assert result is False
+
+    @patch("superset.daos.dataset.DatasetDAO")
+    def test_returns_false_for_numeric_column_with_is_dttm_no_date_format(
+        self, mock_dao
+    ) -> None:
+        """A numeric 'year' column mis-flagged is_dttm=True by naming
+        heuristics, with no python_date_format to parse it, is still
+        rejected to avoid an invalid DATE_TRUNC on a numeric column."""
+        mock_dataset = self._create_mock_dataset(
+            "year", "BIGINT", GenericDataType.NUMERIC, is_dttm=True
+        )
+        mock_dao.find_by_id_or_uuid.return_value = mock_dataset
+
+        result = is_column_truly_temporal("year", 123)
+        assert result is False
+
+    @patch("superset.daos.dataset.DatasetDAO")
+    def test_returns_true_for_numeric_column_with_is_dttm_and_date_format(
+        self, mock_dao
+    ) -> None:
+        """A numeric epoch column explicitly marked is_dttm with a
+        python_date_format (e.g. 'epoch_s') is trusted as temporal."""
+        mock_dataset = self._create_mock_dataset(
+            "created_epoch",
+            "BIGINT",
+            GenericDataType.NUMERIC,
+            is_dttm=True,
+            python_date_format="epoch_s",
+        )
+        mock_dao.find_by_id_or_uuid.return_value = mock_dataset
+
+        result = is_column_truly_temporal("created_epoch", 123)
+        assert result is True
+
 
 class TestConfigureTemporalHandling:
     """Test configure_temporal_handling function"""

Reply via email to