From 0358411a22f5a89f8c6ce8836b8b41f744c30f2b Mon Sep 17 00:00:00 2001 From: Vitor Avila Date: Mon, 22 Jun 2026 15:55:58 -0300 Subject: [PATCH 1/2] fix(chart API): Consider time grain filters with the filters_dashboard_id param --- .../charts/data/dashboard_filter_context.py | 14 +++- .../unit_tests/charts/test_chart_data_api.py | 73 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/superset/charts/data/dashboard_filter_context.py b/superset/charts/data/dashboard_filter_context.py index b7d745b49bcb..648da737ec5f 100644 --- a/superset/charts/data/dashboard_filter_context.py +++ b/superset/charts/data/dashboard_filter_context.py @@ -306,7 +306,7 @@ def get_dashboard_filter_context( return context -def apply_dashboard_filter_context( +def apply_dashboard_filter_context( # noqa: C901 query_context: dict[str, Any], extra_form_data: dict[str, Any], ) -> None: @@ -332,6 +332,18 @@ def apply_dashboard_filter_context( for key in EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS: if key in extra_form_data: extras[key] = extra_form_data[key] + + # EXTRA_FORM_DATA_OVERRIDE_EXTRA_KEYS is originally used with form_data objects, + # not query_context objects. form_data objects expect time_grain_sqla as a + # top-level key, but query_context objects expect it as an extra key. + if custom_time_grain := extra_form_data.get("time_grain_sqla"): + extras["time_grain_sqla"] = custom_time_grain + # Inject ``filter_timegrain`` into X-Axis column + for column in query.get("columns") or []: + if isinstance(column, dict) and column.get("columnType") == "BASE_AXIS": + column["timeGrain"] = custom_time_grain + break + if extras: query["extras"] = extras diff --git a/tests/unit_tests/charts/test_chart_data_api.py b/tests/unit_tests/charts/test_chart_data_api.py index eea180f01da3..0c0fc8f31cf2 100644 --- a/tests/unit_tests/charts/test_chart_data_api.py +++ b/tests/unit_tests/charts/test_chart_data_api.py @@ -113,6 +113,79 @@ def test_apply_dashboard_filter_context_does_not_duplicate_filters( assert ExtraCache().filter_values("country") == ["USA"] +def test_apply_dashboard_filter_context_applies_time_grain_to_extras() -> None: + """ + A dashboard time-grain filter must land in ``query["extras"]``, where + get_time_grain() reads it for charts that have no adhoc x-axis column. + """ + query_context_json: dict[str, Any] = { + "queries": [{"extras": {"time_grain_sqla": "P1D", "having": "", "where": ""}}], + } + + apply_dashboard_filter_context(query_context_json, {"time_grain_sqla": "P1M"}) + + assert query_context_json["queries"][0]["extras"]["time_grain_sqla"] == "P1M" + + +def test_apply_dashboard_filter_context_overrides_x_axis_time_grain() -> None: + """ + For charts with an adhoc X-Axis, the dashboard grain must override the + BASE_AXIS column's ``timeGrain`` (which get_time_grain() reads before + falling back to extras), mirroring the frontend's normalizeTimeColumn. + """ + query_context_json: dict[str, Any] = { + "queries": [ + { + "columns": [ + { + "timeGrain": "P1D", + "columnType": "BASE_AXIS", + "sqlExpression": "order_date", + } + ], + "extras": {"time_grain_sqla": "P1D"}, + } + ], + } + + apply_dashboard_filter_context(query_context_json, {"time_grain_sqla": "P1Y"}) + + query = query_context_json["queries"][0] + assert query["columns"][0]["timeGrain"] == "P1Y" + assert query["extras"]["time_grain_sqla"] == "P1Y" + + +def test_apply_dashboard_filter_context_keeps_grain_when_no_grain_filter() -> None: + """ + When the dashboard applies a non-grain filter (e.g. a value filter), the + chart's own x-axis ``timeGrain`` must be preserved -- not wiped -- since no + dashboard grain was provided. + """ + query_context_json: dict[str, Any] = { + "queries": [ + { + "columns": [ + { + "timeGrain": "P1M", + "columnType": "BASE_AXIS", + "sqlExpression": "order_date", + } + ], + "extras": {"time_grain_sqla": "P1M"}, + } + ], + } + + # extra_form_data carries a value filter but NO time_grain_sqla + apply_dashboard_filter_context( + query_context_json, + {"filters": [{"col": "country", "op": "IN", "val": ["US"]}]}, + ) + + query = query_context_json["queries"][0] + assert query["columns"][0]["timeGrain"] == "P1M" + + def _extract_filename(form_value: str) -> str | None: """Run _extract_export_params_from_request with a form filename value.""" from superset.charts.data.api import ChartDataRestApi From f5dee3bad356fad9b29723e39cbedd9cc4245f3e Mon Sep 17 00:00:00 2001 From: Vitor Avila Date: Mon, 22 Jun 2026 19:29:52 -0300 Subject: [PATCH 2/2] Address PR feedback --- .../charts/data/dashboard_filter_context.py | 9 ++++---- .../unit_tests/charts/test_chart_data_api.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/superset/charts/data/dashboard_filter_context.py b/superset/charts/data/dashboard_filter_context.py index 648da737ec5f..e507390ca365 100644 --- a/superset/charts/data/dashboard_filter_context.py +++ b/superset/charts/data/dashboard_filter_context.py @@ -338,11 +338,10 @@ def apply_dashboard_filter_context( # noqa: C901 # top-level key, but query_context objects expect it as an extra key. if custom_time_grain := extra_form_data.get("time_grain_sqla"): extras["time_grain_sqla"] = custom_time_grain - # Inject ``filter_timegrain`` into X-Axis column - for column in query.get("columns") or []: - if isinstance(column, dict) and column.get("columnType") == "BASE_AXIS": - column["timeGrain"] = custom_time_grain - break + # get_time_grain() resolves grain from the first adhoc column (columns[0]) + columns = query.get("columns") or [] + if columns and isinstance(columns[0], dict): + columns[0]["timeGrain"] = custom_time_grain if extras: query["extras"] = extras diff --git a/tests/unit_tests/charts/test_chart_data_api.py b/tests/unit_tests/charts/test_chart_data_api.py index 0c0fc8f31cf2..a1334c3471f0 100644 --- a/tests/unit_tests/charts/test_chart_data_api.py +++ b/tests/unit_tests/charts/test_chart_data_api.py @@ -155,6 +155,29 @@ def test_apply_dashboard_filter_context_overrides_x_axis_time_grain() -> None: assert query["extras"]["time_grain_sqla"] == "P1Y" +def test_apply_dashboard_filter_context_grain_targets_first_adhoc_column() -> None: + """ + The grain override must land on ``columns[0]`` to match frontend logic. + """ + query_context_json: dict[str, Any] = { + "queries": [ + { + "columns": [ + {"timeGrain": "P1D", "sqlExpression": "order_date"}, + {"columnType": "BASE_AXIS", "sqlExpression": "other"}, + ], + "extras": {}, + } + ], + } + + apply_dashboard_filter_context(query_context_json, {"time_grain_sqla": "P1Y"}) + + columns = query_context_json["queries"][0]["columns"] + assert columns[0]["timeGrain"] == "P1Y" # the column get_time_grain reads + assert "timeGrain" not in columns[1] # the BASE_AXIS-tagged one is untouched + + def test_apply_dashboard_filter_context_keeps_grain_when_no_grain_filter() -> None: """ When the dashboard applies a non-grain filter (e.g. a value filter), the