diff --git a/superset/charts/data/dashboard_filter_context.py b/superset/charts/data/dashboard_filter_context.py index b7d745b49bcb..e507390ca365 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,17 @@ 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 + # 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 eea180f01da3..a1334c3471f0 100644 --- a/tests/unit_tests/charts/test_chart_data_api.py +++ b/tests/unit_tests/charts/test_chart_data_api.py @@ -113,6 +113,102 @@ 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_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 + 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