You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by va...@apache.org on 2026/07/15 17:27:58 UTC

(superset) branch master updated: fix(charts): preserve time filter for expression axes (#42052)

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

Vitor-Avila 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 90f9238f8a5 fix(charts): preserve time filter for expression axes (#42052)
90f9238f8a5 is described below

commit 90f9238f8a5a9cc8b604e74559e2125b0c49a08d
Author: Mike Bridge <mi...@bridgecanada.com>
AuthorDate: Wed Jul 15 11:27:50 2026 -0600

    fix(charts): preserve time filter for expression axes (#42052)
    
    Co-authored-by: Mike Bridge <mi...@ext.preset.io>
---
 superset/common/query_context_factory.py           |  20 ++++
 .../common/test_query_context_factory.py           | 132 +++++++++++++++++++++
 .../unit_tests/models/test_no_filter_time_range.py |  42 ++++++-
 3 files changed, 193 insertions(+), 1 deletion(-)

diff --git a/superset/common/query_context_factory.py b/superset/common/query_context_factory.py
index 0d1539a9a16..50807370495 100644
--- a/superset/common/query_context_factory.py
+++ b/superset/common/query_context_factory.py
@@ -244,6 +244,26 @@ class QueryContextFactory:  # pylint: disable=too-few-public-methods
             if (column["is_dttm"] if isinstance(column, dict) else column.is_dttm)
         }
         x_axis = form_data and form_data.get("x_axis")
+        temporal_range_filters = [
+            filter_
+            for filter_ in query_object.filter
+            if filter_["op"] == "TEMPORAL_RANGE"
+        ]
+
+        should_infer_filter_granularity: bool = (
+            is_adhoc_column(x_axis)  # type: ignore
+            and query_object.granularity is None
+            and bool(query_object.from_dttm or query_object.to_dttm)
+            and (bool(query_object.time_range) or not temporal_range_filters)
+            and (main_dttm_col := getattr(datasource, "main_dttm_col", None))
+            in temporal_columns
+        )
+        if should_infer_filter_granularity:
+            # The inferred column supplies the time-filter subject. It must not be
+            # treated as an explicitly selected granularity, which would rewrite
+            # the x-axis or remove an independent temporal filter below.
+            query_object.granularity = main_dttm_col
+            return
 
         if granularity := query_object.granularity:
             filter_to_remove = None
diff --git a/tests/unit_tests/common/test_query_context_factory.py b/tests/unit_tests/common/test_query_context_factory.py
index 37a7b47460d..3f092c87694 100644
--- a/tests/unit_tests/common/test_query_context_factory.py
+++ b/tests/unit_tests/common/test_query_context_factory.py
@@ -14,6 +14,7 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+from datetime import datetime, timezone
 from unittest.mock import Mock, patch
 
 from superset.common.query_context_factory import QueryContextFactory
@@ -372,6 +373,8 @@ class TestQueryContextFactory:
         """Test _apply_granularity when no granularity is set"""
         query_object = Mock(spec=QueryObject)
         query_object.granularity = None
+        query_object.from_dttm = None
+        query_object.to_dttm = None
         query_object.columns = ["ds", "other_col"]
         query_object.post_processing = []
         query_object.filter = []
@@ -384,6 +387,135 @@ class TestQueryContextFactory:
 
         assert query_object.columns == ["ds", "other_col"]
 
+    def test_apply_granularity_uses_main_datetime_for_bounded_expression_axis(
+        self,
+    ) -> None:
+        """A bounded expression-axis query retains its physical time subject."""
+        query_object = Mock(spec=QueryObject)
+        query_object.granularity = None
+        query_object.from_dttm = datetime(2024, 1, 1, tzinfo=timezone.utc)
+        query_object.to_dttm = datetime(2024, 1, 31, tzinfo=timezone.utc)
+        query_object.time_range = None
+        query_object.columns = [
+            {
+                "expressionType": "SQL",
+                "sqlExpression": "value / 10",
+                "label": "value_bucket",
+            }
+        ]
+        query_object.post_processing = []
+        query_object.filter = []
+
+        form_data = {"x_axis": query_object.columns[0]}
+        datasource = Mock()
+        datasource.main_dttm_col = "ds"
+        datasource.columns = [{"column_name": "ds", "is_dttm": True}]
+
+        self.factory._apply_granularity(query_object, form_data, datasource)
+
+        assert query_object.granularity == "ds"
+        assert query_object.columns[0]["sqlExpression"] == "value / 10"
+
+    def test_apply_granularity_preserves_physical_temporal_axis(self) -> None:
+        """A bounded physical temporal axis is not replaced by the main column."""
+        query_object = Mock(spec=QueryObject)
+        query_object.granularity = None
+        query_object.from_dttm = datetime(2024, 1, 1, tzinfo=timezone.utc)
+        query_object.to_dttm = datetime(2024, 1, 31, tzinfo=timezone.utc)
+        query_object.columns = ["event_end"]
+        query_object.post_processing = []
+        query_object.filter = []
+
+        datasource = Mock()
+        datasource.main_dttm_col = "ds"
+        datasource.columns = [
+            {"column_name": "ds", "is_dttm": True},
+            {"column_name": "event_end", "is_dttm": True},
+        ]
+
+        self.factory._apply_granularity(
+            query_object,
+            {"x_axis": "event_end"},
+            datasource,
+        )
+
+        assert query_object.granularity is None
+        assert query_object.columns == ["event_end"]
+
+    def test_inferred_granularity_preserves_independent_temporal_filter(self) -> None:
+        """Inferring the filter subject does not remove another temporal filter."""
+        expression = {
+            "expressionType": "SQL",
+            "sqlExpression": "value / 10",
+            "label": "value_bucket",
+        }
+        temporal_filter = {
+            "col": "event_end",
+            "op": "TEMPORAL_RANGE",
+            "val": "2024-01-10 : 2024-01-20",
+        }
+        query_object = Mock(spec=QueryObject)
+        query_object.granularity = None
+        query_object.from_dttm = datetime(2024, 1, 1, tzinfo=timezone.utc)
+        query_object.to_dttm = datetime(2024, 1, 31, tzinfo=timezone.utc)
+        query_object.time_range = "2024-01-01 : 2024-01-31"
+        query_object.columns = [expression]
+        query_object.post_processing = []
+        query_object.filter = [temporal_filter]
+
+        datasource = Mock()
+        datasource.main_dttm_col = "ds"
+        datasource.columns = [
+            {"column_name": "ds", "is_dttm": True},
+            {"column_name": "event_end", "is_dttm": True},
+        ]
+
+        self.factory._apply_granularity(
+            query_object,
+            {"x_axis": expression},
+            datasource,
+        )
+
+        assert query_object.granularity == "ds"
+        assert query_object.filter == [temporal_filter]
+
+    def test_granularity_not_inferred_from_another_temporal_filter(self) -> None:
+        """A lone temporal filter remains the only time-filter subject."""
+        expression = {
+            "expressionType": "SQL",
+            "sqlExpression": "value / 10",
+            "label": "value_bucket",
+        }
+        temporal_filter = {
+            "col": "event_end",
+            "op": "TEMPORAL_RANGE",
+            "val": "2024-01-10 : 2024-01-20",
+        }
+        query_object = Mock(spec=QueryObject)
+        query_object.granularity = None
+        query_object.from_dttm = datetime(2024, 1, 10, tzinfo=timezone.utc)
+        query_object.to_dttm = datetime(2024, 1, 20, tzinfo=timezone.utc)
+        query_object.time_range = None
+        query_object.columns = [expression]
+        query_object.post_processing = []
+        query_object.filter = [temporal_filter]
+
+        datasource = Mock()
+        datasource.main_dttm_col = "ds"
+        datasource.columns = [
+            {"column_name": "ds", "is_dttm": True},
+            {"column_name": "event_end", "is_dttm": True},
+        ]
+
+        self.factory._apply_granularity(
+            query_object,
+            {"x_axis": expression},
+            datasource,
+        )
+
+        assert query_object.granularity is None
+        assert query_object.filter == [temporal_filter]
+
     def test_apply_granularity_x_axis_not_temporal(self):
         """Test _apply_granularity when x_axis is not a temporal column"""
         query_object = Mock(spec=QueryObject)
diff --git a/tests/unit_tests/models/test_no_filter_time_range.py b/tests/unit_tests/models/test_no_filter_time_range.py
index 09139920897..07608c9ec24 100644
--- a/tests/unit_tests/models/test_no_filter_time_range.py
+++ b/tests/unit_tests/models/test_no_filter_time_range.py
@@ -23,9 +23,11 @@ from datetime import datetime, timezone
 from flask import Flask
 from pytest_mock import MockerFixture
 
+from superset.common.query_context_factory import QueryContextFactory
+from superset.common.query_object import QueryObject
 from superset.connectors.sqla.models import SqlaTable, SqlMetric, TableColumn
 from superset.models.core import Database
-from superset.superset_typing import QueryObjectDict
+from superset.superset_typing import AdhocColumn, QueryObjectDict
 
 
 def _make_dataset(mocker: MockerFixture) -> SqlaTable:
@@ -208,6 +210,44 @@ def test_actual_from_to_dttm_produces_time_filter(
     )
 
 
+def test_time_range_without_granularity_filters_main_datetime_column(
+    mocker: MockerFixture,
+    app: Flask,
+) -> None:
+    """A SQL-expression dimension must not cause a dashboard range to be dropped."""
+    dataset = _make_dataset(mocker)
+    expression: AdhocColumn = {
+        "sqlExpression": "value / 10",
+        "label": "value_bucket",
+        "columnType": "BASE_AXIS",
+    }
+    query_object: QueryObject = QueryObject(
+        granularity=None,
+        is_timeseries=False,
+        metrics=["count"],
+        columns=[expression],
+        time_range="2024-01-01 : 2024-01-31",
+        from_dttm=datetime(2024, 1, 1, tzinfo=timezone.utc),
+        to_dttm=datetime(2024, 1, 31, tzinfo=timezone.utc),
+    )
+
+    with app.test_request_context():
+        QueryContextFactory()._apply_granularity(
+            query_object,
+            {"x_axis": expression},
+            dataset,
+        )
+        generated_sql = dataset.get_query_str_extended(
+            query_object.to_dict(), mutate=False
+        ).sql.lower()
+
+    assert query_object.granularity == "time_start"
+    assert "value / 10" in generated_sql
+    assert "group by" in generated_sql
+    assert "where time_start >=" in generated_sql
+    assert "time_start <" in generated_sql
+
+
 def test_series_limit_with_time_filter_includes_time_in_inner_subquery(
     mocker: MockerFixture,
     app: Flask,