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,