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 74924ae73a4 fix(db_engine_specs): use CAST(DATE({col}) AS DATETIME) in 
MySQL HOUR time grain (#38617)
74924ae73a4 is described below

commit 74924ae73a41a8beb3b0f161bb8bd8ae136f6882
Author: Nitish Agarwal <[email protected]>
AuthorDate: Tue Jul 28 01:22:25 2026 +0530

    fix(db_engine_specs): use CAST(DATE({col}) AS DATETIME) in MySQL HOUR time 
grain (#38617)
---
 superset/db_engine_specs/mysql.py                  |  9 +--
 tests/unit_tests/db_engine_specs/test_mysql.py     | 88 +++++++++++++++++++++-
 tests/unit_tests/db_engine_specs/test_starrocks.py | 25 +++++-
 3 files changed, 113 insertions(+), 9 deletions(-)

diff --git a/superset/db_engine_specs/mysql.py 
b/superset/db_engine_specs/mysql.py
index 91f13bd55b7..39d1b550cce 100644
--- a/superset/db_engine_specs/mysql.py
+++ b/superset/db_engine_specs/mysql.py
@@ -250,12 +250,9 @@ class MySQLEngineSpec(BasicParametersMixin, 
BaseEngineSpec):
 
     _time_grain_expressions = {
         None: "{col}",
-        TimeGrain.SECOND: "DATE_ADD(DATE({col}), "
-        "INTERVAL (HOUR({col})*60*60 + MINUTE({col})*60"
-        " + SECOND({col})) SECOND)",
-        TimeGrain.MINUTE: "DATE_ADD(DATE({col}), "
-        "INTERVAL (HOUR({col})*60 + MINUTE({col})) MINUTE)",
-        TimeGrain.HOUR: "DATE_ADD(DATE({col}), INTERVAL HOUR({col}) HOUR)",
+        TimeGrain.SECOND: "DATE_FORMAT({col}, '%Y-%m-%d %H:%i:%s')",
+        TimeGrain.MINUTE: "DATE_FORMAT({col}, '%Y-%m-%d %H:%i:00')",
+        TimeGrain.HOUR: "DATE_FORMAT({col}, '%Y-%m-%d %H:00:00')",
         TimeGrain.DAY: "DATE({col})",
         TimeGrain.WEEK: "DATE(DATE_SUB({col}, INTERVAL DAYOFWEEK({col}) - 1 
DAY))",
         TimeGrain.MONTH: "DATE(DATE_SUB({col}, INTERVAL DAYOFMONTH({col}) - 1 
DAY))",
diff --git a/tests/unit_tests/db_engine_specs/test_mysql.py 
b/tests/unit_tests/db_engine_specs/test_mysql.py
index 64de9d479af..2ea2137dfce 100644
--- a/tests/unit_tests/db_engine_specs/test_mysql.py
+++ b/tests/unit_tests/db_engine_specs/test_mysql.py
@@ -23,7 +23,7 @@ from typing import Any, Optional
 from unittest.mock import Mock, patch
 
 import pytest
-from sqlalchemy import types
+from sqlalchemy import column, types
 from sqlalchemy.dialects.mysql import (
     BIT,
     DECIMAL,
@@ -38,6 +38,8 @@ from sqlalchemy.dialects.mysql import (
 )
 from sqlalchemy.engine.url import make_url, URL  # noqa: F401
 
+from superset.constants import TimeGrain
+from superset.db_engine_specs.base import TimestampExpression
 from superset.utils.core import GenericDataType
 from tests.unit_tests.db_engine_specs.utils import (
     assert_column_spec,
@@ -304,3 +306,87 @@ def test_get_datatype_pymysql_fallback():
     finally:
         # Restore original state
         MySQLEngineSpec.type_code_map = original_type_code_map
+
+
[email protected](
+    ("grain", "expected_expression"),
+    [
+        (None, "my_col"),
+        (
+            TimeGrain.SECOND,
+            "DATE_FORMAT(my_col, '%Y-%m-%d %H:%i:%s')",
+        ),
+        (
+            TimeGrain.MINUTE,
+            "DATE_FORMAT(my_col, '%Y-%m-%d %H:%i:00')",
+        ),
+        (
+            TimeGrain.HOUR,
+            "DATE_FORMAT(my_col, '%Y-%m-%d %H:00:00')",
+        ),
+        (TimeGrain.DAY, "DATE(my_col)"),
+        (
+            TimeGrain.WEEK,
+            "DATE(DATE_SUB(my_col, INTERVAL DAYOFWEEK(my_col) - 1 DAY))",
+        ),
+        (
+            TimeGrain.MONTH,
+            "DATE(DATE_SUB(my_col, INTERVAL DAYOFMONTH(my_col) - 1 DAY))",
+        ),
+        (
+            TimeGrain.QUARTER,
+            "MAKEDATE(YEAR(my_col), 1) "
+            "+ INTERVAL QUARTER(my_col) QUARTER - INTERVAL 1 QUARTER",
+        ),
+        (
+            TimeGrain.YEAR,
+            "DATE(DATE_SUB(my_col, INTERVAL DAYOFYEAR(my_col) - 1 DAY))",
+        ),
+        (
+            TimeGrain.WEEK_STARTING_MONDAY,
+            "DATE(DATE_SUB(my_col, "
+            "INTERVAL DAYOFWEEK(DATE_SUB(my_col, "
+            "INTERVAL 1 DAY)) - 1 DAY))",
+        ),
+    ],
+)
+def test_time_grain_expressions(
+    grain: Optional[TimeGrain], expected_expression: str
+) -> None:
+    """
+    Test that MySQL time grain expression templates produce the expected SQL.
+    Guards against the bare DATE() call being dropped by SQLGlot sanitization
+    or SQLAlchemy proxying for the SECOND/MINUTE/HOUR grains, which used to
+    truncate to a bare `DATE({col})`.
+    """
+    from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+    actual = MySQLEngineSpec._time_grain_expressions[grain].replace("{col}", 
"my_col")
+    assert actual == expected_expression
+
+
+def test_compile_timegrain_expression_preserves_date_truncation() -> None:
+    """
+    Test that compile_timegrain_expression preserves the full DATE_FORMAT
+    truncation in the MySQL HOUR time grain expression, including when the
+    expression is proxied through a subquery (series-limit path).
+
+    Regression test for: ECharts HOUR grain generates invalid SQL (DATE()
+    dropped by sanitization/proxying).
+    """
+    from sqlalchemy import select
+
+    from superset.db_engine_specs.mysql import MySQLEngineSpec
+
+    col = column("my_col")
+    template = MySQLEngineSpec._time_grain_expressions[TimeGrain.HOUR]
+    expr = TimestampExpression(template, col)
+    expected = "DATE_FORMAT(my_col, '%Y-%m-%d %H:00:00')"
+
+    compiled = str(expr)
+    assert compiled == expected, f"DATE_FORMAT truncation was dropped. Got: 
{compiled}"
+
+    proxied = str(select(select(expr.label("bucket")).subquery().c.bucket))
+    assert expected in proxied, (
+        f"DATE_FORMAT truncation was dropped in proxied expression. Got: 
{proxied}"
+    )
diff --git a/tests/unit_tests/db_engine_specs/test_starrocks.py 
b/tests/unit_tests/db_engine_specs/test_starrocks.py
index 42779062ef1..5590c9d70d7 100644
--- a/tests/unit_tests/db_engine_specs/test_starrocks.py
+++ b/tests/unit_tests/db_engine_specs/test_starrocks.py
@@ -22,6 +22,7 @@ from pytest_mock import MockerFixture
 from sqlalchemy import JSON, types
 from sqlalchemy.engine.url import make_url
 
+from superset.constants import TimeGrain
 from superset.db_engine_specs.starrocks import (
     ARRAY,
     BITMAP,
@@ -231,8 +232,8 @@ def test_get_catalog_names(mocker: MockerFixture) -> None:
     # StarRocks returns rows with keys: ['Catalog', 'Type', 'Comment']
     mock_row_1 = mocker.MagicMock()
     mock_row_1.keys.return_value = ["Catalog", "Type", "Comment"]
-    mock_row_1.__getitem__ = (
-        lambda self, key: "default_catalog" if key == "Catalog" else None
+    mock_row_1.__getitem__ = lambda self, key: (
+        "default_catalog" if key == "Catalog" else None
     )
 
     mock_row_2 = mocker.MagicMock()
@@ -364,3 +365,23 @@ def 
test_get_prequeries_with_email_prefix_from_user_email_when_effective_user_di
     assert StarRocksEngineSpec.get_prequeries(database) == [
         'EXECUTE AS "alice.doe" WITH NO REVERT;'
     ]
+
+
+def test_time_grain_expressions_inherit_mysql() -> None:
+    """
+    Test that StarRocksEngineSpec inherits MySQL time grain expressions and
+    that the HOUR grain renders to the correct DATE_FORMAT truncation SQL.
+
+    StarRocks does not override _time_grain_expressions, so it reuses MySQL's
+    templates, including the HOUR grain. This asserts the rendered HOUR output
+    (not object identity) to guard against the HOUR-grain truncation 
regression.
+
+    Regression test for: ECharts HOUR grain generated invalid, over-truncated
+    SQL when the grain expression was normalized or proxied (#36798).
+    """
+    from superset.db_engine_specs.starrocks import StarRocksEngineSpec
+
+    actual = 
StarRocksEngineSpec._time_grain_expressions[TimeGrain.HOUR].replace(
+        "{col}", "my_col"
+    )
+    assert actual == "DATE_FORMAT(my_col, '%Y-%m-%d %H:00:00')"

Reply via email to