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

aminghadersohi 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 74eb298eb68 fix(mcp): preserve chart form data on update (#43679)
74eb298eb68 is described below

commit 74eb298eb6860386828c093b5fd4d3ec732b388c
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Wed Sep 9 17:43:04 2026 -0400

    fix(mcp): preserve chart form data on update (#43679)
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 superset/mcp_service/chart/chart_utils.py          |  73 ++++-
 superset/mcp_service/chart/tool/update_chart.py    | 167 +++++++++++-
 .../mcp_service/chart/tool/test_update_chart.py    | 303 ++++++++++++++++++++-
 .../chart/tool/test_update_chart_preview.py        | 287 +++++++++++++++++++
 4 files changed, 821 insertions(+), 9 deletions(-)

diff --git a/superset/mcp_service/chart/chart_utils.py 
b/superset/mcp_service/chart/chart_utils.py
index d8ad21141a5..bc3de192924 100644
--- a/superset/mcp_service/chart/chart_utils.py
+++ b/superset/mcp_service/chart/chart_utils.py
@@ -689,9 +689,20 @@ def merge_chart_form_data(  # noqa: C901
     if not isinstance(config, GaugeChartConfig):
         if dataset_rebind:
             return dict(new_form_data)
+        fields_set = config.model_fields_set
+        if "filters" not in fields_set:
+            preserve_previous_adhoc_filters(new_form_data, existing_form_data)
         merged = {**existing_form_data, **new_form_data}
-        if getattr(config, "filters", None) == []:
-            merged.pop("adhoc_filters", None)
+        # An explicitly empty collection clears the control rather than
+        # falling through to the inherited value.
+        for config_field, form_data_field in (
+            ("filters", "adhoc_filters"),
+            ("group_by", "groupby"),
+            ("group_by_secondary", "groupby_b"),
+            ("sort_by", "order_by_cols"),
+        ):
+            if config_field in fields_set and getattr(config, config_field, 
None) == []:
+                merged.pop(form_data_field, None)
         return merged
 
     fields_set = config.model_fields_set
@@ -2011,3 +2022,61 @@ def analyze_chart_semantics(viz_type: str | None, 
config: Any) -> ChartSemantics
         anomalies=[],  # Would need actual data analysis to populate
         statistical_summary={},  # Would need actual data analysis to populate
     )
+
+
+def preserve_previous_adhoc_filters(
+    new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
+) -> None:
+    """Preserve saved filters without dropping mapper-generated bindings.
+
+    Saved predicates the caller did not mention survive the update, while the
+    bindings generated for the new config are appended when they are not
+    already represented. A stale temporal binding is dropped when the config
+    rebinds the time filter to a different subject.
+    """
+    previous_filters = previous_form_data.get("adhoc_filters")
+    if not isinstance(previous_filters, list) or not previous_filters:
+        return
+
+    generated_filters = new_form_data.get("adhoc_filters", [])
+    previous_binding = 
previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
+    merged_filters = [
+        filter_
+        for filter_ in previous_filters
+        if not (
+            previous_binding
+            and previous_binding != new_binding
+            and isinstance(filter_, dict)
+            and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
+            and filter_.get("subject") == previous_binding
+            and filter_.get("comparator") == NO_TIME_RANGE
+        )
+    ]
+    for generated_filter in generated_filters:
+        if not isinstance(generated_filter, dict):
+            if generated_filter not in merged_filters:
+                merged_filters.append(generated_filter)
+            continue
+
+        # A saved temporal predicate on the same subject wins over the
+        # generated default, so only the comparator-insensitive match is
+        # treated as already represented for TEMPORAL_RANGE.
+        is_same_filter = any(
+            isinstance(previous_filter, dict)
+            and previous_filter.get("clause") == generated_filter.get("clause")
+            and previous_filter.get("expressionType")
+            == generated_filter.get("expressionType")
+            and previous_filter.get("subject") == 
generated_filter.get("subject")
+            and previous_filter.get("operator") == 
generated_filter.get("operator")
+            and (
+                generated_filter.get("operator") == 
FilterOperator.TEMPORAL_RANGE.value
+                or previous_filter.get("comparator")
+                == generated_filter.get("comparator")
+            )
+            for previous_filter in merged_filters
+        )
+        if not is_same_filter:
+            merged_filters.append(generated_filter)
+
+    new_form_data["adhoc_filters"] = merged_filters
diff --git a/superset/mcp_service/chart/tool/update_chart.py 
b/superset/mcp_service/chart/tool/update_chart.py
index 0bc76fb6ee6..b011895f120 100644
--- a/superset/mcp_service/chart/tool/update_chart.py
+++ b/superset/mcp_service/chart/tool/update_chart.py
@@ -49,6 +49,7 @@ from superset.mcp_service.chart.schemas import (
     AccessibilityMetadata,
     ChartConfig,
     ColumnRef,
+    GaugeChartConfig,
     GenerateChartResponse,
     PerformanceMetadata,
     TableChartConfig,
@@ -211,6 +212,151 @@ def _merge_replacement_config(
     )
 
 
+def _valid_dataset_reference(
+    value: Any,
+    columns: set[str],
+    metrics: set[str],
+    *,
+    allow_metric: bool = False,
+) -> bool:
+    """Return whether a form_data reference resolves against a dataset."""
+    if not isinstance(value, str):
+        return True
+    normalized = value.casefold()
+    return normalized in columns or (allow_metric and normalized in metrics)
+
+
+def _inherited_metrics_match_dataset(
+    existing_form_data: dict[str, Any],
+    columns: set[str],
+    metrics: set[str],
+) -> bool:
+    for metric in existing_form_data.get("metrics") or []:
+        if isinstance(metric, str) and not _valid_dataset_reference(
+            metric, columns, metrics, allow_metric=True
+        ):
+            return False
+        if isinstance(metric, dict):
+            column = metric.get("column")
+            if isinstance(column, dict) and not _valid_dataset_reference(
+                column.get("column_name"), columns, metrics
+            ):
+                return False
+    return True
+
+
+def _inherited_sort_matches_dataset(
+    order_by_cols: Any, columns: set[str], metrics: set[str]
+) -> bool:
+    for order_by in order_by_cols or []:
+        try:
+            column = json.loads(order_by)[0]
+        except (TypeError, ValueError, IndexError):
+            return False
+        if not _valid_dataset_reference(column, columns, metrics, 
allow_metric=True):
+            return False
+    return True
+
+
+def _inherited_filters_match_dataset(
+    filters: Any, columns: set[str], metrics: set[str]
+) -> bool:
+    for filter_ in filters or []:
+        if not isinstance(filter_, dict):
+            return False
+        if filter_.get("expressionType") not in (None, "SIMPLE"):
+            return False
+        subject = filter_.get("subject") or filter_.get("col")
+        allow_metric = str(filter_.get("clause", "WHERE")).upper() == "HAVING"
+        if not _valid_dataset_reference(
+            subject, columns, metrics, allow_metric=allow_metric
+        ):
+            return False
+    return True
+
+
+#: form_data keys carrying query roles, mapped to the config field that
+#: sets them explicitly. An explicit field is the caller's stated intent,
+#: so it is never treated as inherited state.
+_INHERITED_QUERY_ROLE_FIELDS = {
+    "groupby": "group_by",
+    "groupby_b": "group_by_secondary",
+    "all_columns": None,
+    "columns": None,
+    "x_axis": None,
+    "granularity_sqla": None,
+    "metrics": None,
+    "order_by_cols": "sort_by",
+    "adhoc_filters": "filters",
+}
+
+_INHERITED_COLUMN_LIST_KEYS = frozenset(
+    {"groupby", "groupby_b", "all_columns", "columns"}
+)
+_INHERITED_COLUMN_SCALAR_KEYS = frozenset({"x_axis", "granularity_sqla"})
+
+
+def _inherited_state_invalid_keys(
+    existing_form_data: dict[str, Any],
+    new_form_data: dict[str, Any],
+    parsed_config: ChartConfig,
+    dataset_id: int,
+) -> set[str]:
+    """Return inherited query fields that are invalid for a new dataset.
+
+    A dataset rebind only has to discard the state that cannot resolve
+    against the replacement dataset; everything else stays valid and is
+    preserved so the update does not silently reset the chart.
+    """
+    fields_set = parsed_config.model_fields_set
+    inherited_keys = {
+        key
+        for key, config_field in _INHERITED_QUERY_ROLE_FIELDS.items()
+        if key not in new_form_data
+        and config_field not in fields_set
+        and existing_form_data.get(key)
+    }
+    if not inherited_keys:
+        return set()
+
+    from superset.daos.dataset import DatasetDAO
+    from superset.mcp_service.chart.validation.dataset_validator import (
+        build_dataset_context_from_orm,
+    )
+
+    context = build_dataset_context_from_orm(DatasetDAO.find_by_id(dataset_id))
+    if context is None:
+        # The replacement dataset cannot be inspected, so no inherited
+        # reference can be shown to be safe.
+        return inherited_keys
+    columns = {column["name"].casefold() for column in 
context.available_columns}
+    metrics = {metric["name"].casefold() for metric in 
context.available_metrics}
+
+    invalid_keys: set[str] = set()
+    for key in inherited_keys & _INHERITED_COLUMN_LIST_KEYS:
+        values = existing_form_data.get(key)
+        if isinstance(values, list) and not all(
+            _valid_dataset_reference(value, columns, metrics) for value in 
values
+        ):
+            invalid_keys.add(key)
+    for key in inherited_keys & _INHERITED_COLUMN_SCALAR_KEYS:
+        if not _valid_dataset_reference(existing_form_data.get(key), columns, 
metrics):
+            invalid_keys.add(key)
+    if "metrics" in inherited_keys and not _inherited_metrics_match_dataset(
+        existing_form_data, columns, metrics
+    ):
+        invalid_keys.add("metrics")
+    if "order_by_cols" in inherited_keys and not 
_inherited_sort_matches_dataset(
+        existing_form_data.get("order_by_cols"), columns, metrics
+    ):
+        invalid_keys.add("order_by_cols")
+    if "adhoc_filters" in inherited_keys and not 
_inherited_filters_match_dataset(
+        existing_form_data.get("adhoc_filters"), columns, metrics
+    ):
+        invalid_keys.add("adhoc_filters")
+    return invalid_keys
+
+
 def _build_replacement_form_data(
     existing_form_data: dict[str, Any],
     parsed_config: ChartConfig,
@@ -222,13 +368,32 @@ def _build_replacement_form_data(
         parsed_config, dataset_id=effective_dataset_id
     )
     new_form_data.pop("_mcp_warnings", None)
+    dataset_rebind = replacement_dataset_id is not None
+    if replacement_dataset_id is not None and not isinstance(
+        parsed_config, GaugeChartConfig
+    ):
+        # Drop only the inherited state the replacement dataset cannot
+        # resolve, then merge as a same-dataset update. Gauge keeps the
+        # stricter presentation-only rebind handled downstream.
+        invalid_keys = _inherited_state_invalid_keys(
+            existing_form_data,
+            new_form_data,
+            parsed_config,
+            replacement_dataset_id,
+        )
+        existing_form_data = {
+            key: value
+            for key, value in existing_form_data.items()
+            if key not in invalid_keys
+        }
+        dataset_rebind = False
     merge_table_column_config(existing_form_data, new_form_data)
     merge_interactive_pivot_ui_config(existing_form_data, new_form_data)
     merged = _merge_replacement_config(
         existing_form_data,
         new_form_data,
         parsed_config,
-        dataset_rebind=replacement_dataset_id is not None,
+        dataset_rebind=dataset_rebind,
     )
     if replacement_dataset_id is not None:
         merged["datasource"] = f"{replacement_dataset_id}__table"
diff --git a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py 
b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
index 254066711f7..7ea2aafa9c5 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart.py
@@ -37,6 +37,7 @@ from superset.mcp_service.chart.schemas import (
     GaugeChartConfig,
     GenerateChartResponse,
     LegendConfig,
+    MixedTimeseriesChartConfig,
     TableChartConfig,
     UpdateChartRequest,
     XYChartConfig,
@@ -44,6 +45,7 @@ from superset.mcp_service.chart.schemas import (
 from superset.mcp_service.chart.tool.update_chart import (
     _build_preview_form_data,
     _build_update_payload,
+    _inherited_state_invalid_keys,
 )
 from superset.utils import json
 
@@ -739,6 +741,288 @@ class TestBuildUpdatePayload:
         # query_context must be cleared so get_chart_data uses updated params
         assert result["query_context"] is None
 
+    def test_config_update_preserves_unrelated_mixed_timeseries_settings(self):
+        """Save payload retains settings outside the simplified config 
schema."""
+        config = MixedTimeseriesChartConfig(
+            x=ColumnRef(name="ds"),
+            y=[ColumnRef(name="new_primary", aggregate="SUM")],
+            y_secondary=[ColumnRef(name="new_secondary", aggregate="SUM")],
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Year-over-year metrics",
+            params=json.dumps(
+                {
+                    "viz_type": "mixed_timeseries",
+                    "time_compare": ["1 year ago"],
+                    "comparison_type_b": "percentage",
+                    "y_axis_format": ",.2f",
+                }
+            ),
+        )
+
+        result = _build_update_payload(request, chart, parsed_config=config)
+
+        assert isinstance(result, dict)
+        saved_form_data = json.loads(result["params"])
+        assert saved_form_data["time_compare"] == ["1 year ago"]
+        assert saved_form_data["comparison_type_b"] == "percentage"
+        assert saved_form_data["y_axis_format"] == ",.2f"
+
+    @patch(
+        "superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+        return_value=True,
+    )
+    def test_temporal_update_preserves_omitted_non_temporal_filters(
+        self, unused_temporal_mock
+    ) -> None:
+        """Generated time bindings do not replace omitted saved predicates."""
+        country_filter = {
+            "clause": "WHERE",
+            "comparator": "US",
+            "expressionType": "SIMPLE",
+            "operator": "==",
+            "subject": "country",
+        }
+        temporal_filter = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+        config = XYChartConfig(
+            x=ColumnRef(name="ds"),
+            y=[ColumnRef(name="revenue", aggregate="SUM")],
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Revenue",
+            params=json.dumps(
+                {
+                    "viz_type": "echarts_timeseries_line",
+                    "adhoc_filters": [country_filter, temporal_filter],
+                    "_mcp_dashboard_time_filter_subject": "ds",
+                }
+            ),
+        )
+
+        saved = _build_update_payload(request, chart, parsed_config=config)
+        preview = _build_preview_form_data(request, chart, 
parsed_config=config)
+
+        assert isinstance(saved, dict)
+        assert isinstance(preview, dict)
+        saved_filters = json.loads(saved["params"])["adhoc_filters"]
+        assert saved_filters == [country_filter, temporal_filter]
+        assert preview["adhoc_filters"] == saved_filters
+
+    def test_explicit_empty_group_by_clears_save_and_preview(self) -> None:
+        config = XYChartConfig.model_validate(
+            {
+                "x": {"name": "ds"},
+                "y": [{"name": "revenue", "aggregate": "SUM"}],
+                "groupby": [],
+            }
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Revenue by region",
+            params=json.dumps(
+                {
+                    "viz_type": "echarts_timeseries_line",
+                    "groupby": ["region"],
+                }
+            ),
+        )
+
+        saved = _build_update_payload(request, chart, parsed_config=config)
+        preview = _build_preview_form_data(request, chart, 
parsed_config=config)
+
+        assert isinstance(saved, dict)
+        assert isinstance(preview, dict)
+        assert "groupby" not in json.loads(saved["params"])
+        assert "groupby" not in preview
+
+    def test_explicit_empty_sort_by_clears_save_and_preview(self) -> None:
+        config = TableChartConfig.model_validate(
+            {
+                "columns": [{"name": "region"}],
+                "order_by_cols": [],
+            }
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Regions",
+            params=json.dumps(
+                {
+                    "viz_type": "table",
+                    "order_by_cols": ['["region", false]'],
+                }
+            ),
+        )
+
+        saved = _build_update_payload(request, chart, parsed_config=config)
+        preview = _build_preview_form_data(request, chart, 
parsed_config=config)
+
+        assert isinstance(saved, dict)
+        assert isinstance(preview, dict)
+        assert "order_by_cols" not in json.loads(saved["params"])
+        assert "order_by_cols" not in preview
+
+    @patch.object(update_chart_module, "_inherited_state_invalid_keys")
+    def test_dataset_rebind_only_preserves_compatible_state(
+        self, mock_invalid_keys
+    ) -> None:
+        config = TableChartConfig(columns=[ColumnRef(name="revenue")])
+        request = UpdateChartRequest(identifier=1, config=config, dataset_id=9)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Revenue",
+            params=json.dumps(
+                {
+                    "viz_type": "table",
+                    "groupby": ["removed_column"],
+                    "custom_flag": True,
+                }
+            ),
+        )
+
+        mock_invalid_keys.return_value = {"groupby"}
+        incompatible = _build_update_payload(request, chart, 
parsed_config=config)
+        mock_invalid_keys.return_value = set()
+        compatible = _build_update_payload(request, chart, 
parsed_config=config)
+
+        assert isinstance(incompatible, dict)
+        assert isinstance(compatible, dict)
+        incompatible_params = json.loads(incompatible["params"])
+        compatible_params = json.loads(compatible["params"])
+        assert "groupby" not in incompatible_params
+        assert incompatible_params["custom_flag"] is True
+        assert compatible_params["groupby"] == ["removed_column"]
+        assert compatible_params["custom_flag"] is True
+        assert incompatible_params["datasource"] == "9__table"
+        assert compatible_params["datasource"] == "9__table"
+
+    @patch(
+        "superset.mcp_service.chart.validation.dataset_validator."
+        "build_dataset_context_from_orm"
+    )
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_dataset_rebind_explicit_clear_preserves_other_state(
+        self, mock_find_dataset, mock_build_context
+    ) -> None:
+        mock_find_dataset.return_value = Mock()
+        mock_build_context.return_value = Mock(
+            available_columns=[{"name": "revenue"}],
+            available_metrics=[],
+        )
+        config = XYChartConfig.model_validate(
+            {
+                "x": {"name": "revenue"},
+                "y": [{"name": "revenue", "aggregate": "SUM"}],
+                "groupby": [],
+            }
+        )
+        request = UpdateChartRequest(identifier=1, config=config, dataset_id=9)
+        chart = Mock(
+            datasource_id=7,
+            slice_name="Revenue",
+            params=json.dumps(
+                {
+                    "viz_type": "echarts_timeseries_line",
+                    "groupby": ["removed_column"],
+                    "custom_flag": True,
+                }
+            ),
+        )
+
+        result = _build_update_payload(request, chart, parsed_config=config)
+
+        assert isinstance(result, dict)
+        params = json.loads(result["params"])
+        assert "groupby" not in params
+        assert params["custom_flag"] is True
+
+    @patch(
+        "superset.mcp_service.chart.validation.dataset_validator."
+        "build_dataset_context_from_orm"
+    )
+    @patch("superset.daos.dataset.DatasetDAO.find_by_id")
+    def test_dataset_rebind_compatibility_uses_inherited_references(
+        self, mock_find_dataset, mock_build_context
+    ) -> None:
+        mock_find_dataset.return_value = Mock()
+        mock_build_context.return_value = Mock(
+            available_columns=[{"name": "revenue"}, {"name": "region"}],
+            available_metrics=[],
+        )
+        config = TableChartConfig(columns=[ColumnRef(name="revenue")])
+        new_form_data = {"viz_type": "table", "all_columns": ["revenue"]}
+
+        assert not _inherited_state_invalid_keys(
+            {
+                "viz_type": "table",
+                "groupby": ["region"],
+                "adhoc_filters": [
+                    {
+                        "expressionType": "SIMPLE",
+                        "subject": "region",
+                        "operator": "==",
+                        "comparator": "US",
+                    }
+                ],
+            },
+            new_form_data,
+            config,
+            9,
+        )
+        assert _inherited_state_invalid_keys(
+            {
+                "viz_type": "table",
+                "groupby": ["removed_column"],
+            },
+            new_form_data,
+            config,
+            9,
+        ) == {"groupby"}
+
+    def test_config_update_does_not_merge_settings_from_another_viz_type(self):
+        """Changing visualization types drops stale query-defining settings."""
+        config = XYChartConfig(
+            x=ColumnRef(name="ds"),
+            y=[ColumnRef(name="revenue", aggregate="SUM")],
+        )
+        request = UpdateChartRequest(identifier=1, config=config)
+        chart = Mock(
+            id=1,
+            datasource_id=7,
+            slice_name="Raw records",
+            params=json.dumps(
+                {
+                    "viz_type": "table",
+                    "query_mode": "raw",
+                    "all_columns": ["ds", "revenue"],
+                }
+            ),
+        )
+
+        result = _build_update_payload(request, chart, parsed_config=config)
+
+        assert isinstance(result, dict)
+        saved_form_data = json.loads(result["params"])
+        assert saved_form_data["viz_type"] == "echarts_timeseries_line"
+        assert "query_mode" not in saved_form_data
+        assert "all_columns" not in saved_form_data
+
     def test_add_columns_preserves_existing_columns_and_metrics(self):
         """An additive update does not require reconstructing the table."""
         request = UpdateChartRequest(
@@ -1160,7 +1444,9 @@ class TestUpdateChartPreviewFirst:
         mock_chart.slice_name = "Existing Chart"
         mock_chart.viz_type = "table"
         mock_chart.uuid = "abc-123"
-        mock_chart.params = '{"viz_type": "table", "datasource": "10__table"}'
+        mock_chart.params = (
+            '{"viz_type": "table", "datasource": "10__table", "custom_flag": 
true}'
+        )
         mock_find_by_id.return_value = mock_chart
 
         mock_check_access.return_value = DatasetValidationResult(
@@ -1196,6 +1482,7 @@ class TestUpdateChartPreviewFirst:
             # Ensure the chart was NOT persisted
             mock_update_cmd_cls.assert_not_called()
             mock_create_preview.assert_called_once()
+            assert mock_create_preview.call_args.args[1]["custom_flag"] is True
 
     @patch.object(update_chart_module, "_create_preview_url", 
new_callable=Mock)
     @patch(
@@ -1252,13 +1539,15 @@ class TestBuildPreviewFormData:
         chart.id = 42
         chart.datasource_id = 7
         chart.slice_name = "Existing"
-        chart.params = '{"viz_type": "line", "custom_flag": true}'
+        chart.params = '{"viz_type": "table", "custom_flag": true}'
 
         result = _build_preview_form_data(request, chart, parsed_config=config)
 
         assert isinstance(result, dict)
-        # Cross-viz updates do not inherit stale controls from the prior 
plugin.
-        assert "custom_flag" not in result
+        # A same-viz update keeps controls the config does not mention.
+        # Cross-viz drops are covered by
+        # test_config_update_does_not_merge_settings_from_another_viz_type.
+        assert result["custom_flag"] is True
         # New config overrides existing keys
         assert result["viz_type"] == "table"
         # slice_id and datasource are always stamped onto the preview
@@ -1560,7 +1849,7 @@ class TestUpdateChartSaveWithConfig:
         mock_chart.slice_name = "Pre-save"
         mock_chart.viz_type = "table"
         mock_chart.uuid = "uuid-77"
-        mock_chart.params = '{"viz_type": "table"}'
+        mock_chart.params = '{"viz_type": "table", "custom_flag": true}'
         mock_find_by_id.return_value = mock_chart
 
         mock_check_access.return_value = DatasetValidationResult(
@@ -1600,6 +1889,7 @@ class TestUpdateChartSaveWithConfig:
         # Verify query_context is cleared so get_chart_data uses updated params
         payload = mock_update_cmd_cls.call_args[0][1]
         assert payload["query_context"] is None
+        assert json.loads(payload["params"])["custom_flag"] is True
 
         # Verify form_data is returned in the response
         form_data = result.structured_content["form_data"]
@@ -2214,7 +2504,7 @@ class TestBuildUpdatePayloadDatasetId:
             columns=[ColumnRef(name="col1")],
         )
         request = UpdateChartRequest(identifier=1, config=config, 
dataset_id=99)
-        chart = Mock()
+        chart = Mock(params='{"viz_type":"table","datasource":"10__table"}')
         chart.datasource_id = 10
         chart.slice_name = "Old Name"
 
@@ -2225,6 +2515,7 @@ class TestBuildUpdatePayloadDatasetId:
         assert result["datasource_type"] == "table"
         assert "params" in result
         assert "viz_type" in result
+        assert json.loads(result["params"])["datasource"] == "99__table"
 
     def test_config_without_dataset_does_not_include_datasource(self):
         """When dataset_id is None, payload must NOT include datasource_id."""
diff --git 
a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py 
b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
index cc04860eb82..e7306bb993a 100644
--- a/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
+++ b/tests/unit_tests/mcp_service/chart/tool/test_update_chart_preview.py
@@ -21,6 +21,7 @@ Unit tests for update_chart_preview MCP tool
 
 import importlib
 from contextlib import nullcontext
+from typing import Any
 from unittest.mock import Mock, patch
 
 import pytest
@@ -28,8 +29,13 @@ from fastmcp import Client
 
 from superset.extensions import feature_flag_manager
 from superset.mcp_service.app import mcp
+from superset.mcp_service.chart.chart_utils import (
+    map_big_number_config,
+    preserve_previous_adhoc_filters,
+)
 from superset.mcp_service.chart.schemas import (
     AxisConfig,
+    BigNumberChartConfig,
     ColumnRef,
     FilterConfig,
     GaugeChartConfig,
@@ -683,6 +689,287 @@ class TestUpdateChartPreview:
 
         assert result is None
 
+    def test_preserves_generated_temporal_filter_with_cached_filters(self) -> 
None:
+        """Cached filters are merged without replacing the temporal binding."""
+        new_form_data = {
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "comparator": "No filter",
+                    "expressionType": "SIMPLE",
+                    "operator": "TEMPORAL_RANGE",
+                    "subject": "ds",
+                }
+            ]
+        }
+        previous_form_data = {
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "comparator": "North",
+                    "expressionType": "SIMPLE",
+                    "operator": "==",
+                    "subject": "region",
+                }
+            ]
+        }
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            previous_form_data,
+        )
+
+        assert [filter_["subject"] for filter_ in 
new_form_data["adhoc_filters"]] == [
+            "region",
+            "ds",
+        ]
+
+    def test_cached_temporal_filter_takes_precedence_over_generated_default(
+        self,
+    ) -> None:
+        """A cached chart-specific time range is not duplicated or reset."""
+        new_form_data = {
+            "adhoc_filters": [
+                {
+                    "clause": "WHERE",
+                    "comparator": "No filter",
+                    "expressionType": "SIMPLE",
+                    "operator": "TEMPORAL_RANGE",
+                    "subject": "ds",
+                }
+            ]
+        }
+        cached_temporal_filter = {
+            "clause": "WHERE",
+            "comparator": "Last month",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {"adhoc_filters": [cached_temporal_filter]},
+        )
+
+        assert new_form_data["adhoc_filters"] == [cached_temporal_filter]
+
+    def test_preserves_filters_with_distinct_comparators(self) -> None:
+        """Filters that differ only by value are not deduplicated."""
+        previous_filter = {
+            "clause": "WHERE",
+            "comparator": 2020,
+            "expressionType": "SIMPLE",
+            "operator": ">",
+            "subject": "year",
+        }
+        generated_filter = {**previous_filter, "comparator": 2021}
+        new_form_data = {"adhoc_filters": [generated_filter]}
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {"adhoc_filters": [previous_filter]},
+        )
+
+        assert new_form_data["adhoc_filters"] == [previous_filter, 
generated_filter]
+
+    def test_replaces_cached_temporal_filter_when_column_changes(self) -> None:
+        """A newly selected temporal column replaces the cached binding."""
+        new_temporal_filter = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "created_at",
+        }
+        region_filter = {
+            "clause": "WHERE",
+            "comparator": "North",
+            "expressionType": "SIMPLE",
+            "operator": "==",
+            "subject": "region",
+        }
+        previous_temporal_filter = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+        new_form_data: dict[str, Any] = {"adhoc_filters": 
[new_temporal_filter]}
+        new_form_data["_mcp_dashboard_time_filter_subject"] = "created_at"
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {
+                "adhoc_filters": [region_filter, previous_temporal_filter],
+                "_mcp_dashboard_time_filter_subject": "ds",
+            },
+        )
+
+        assert new_form_data["adhoc_filters"] == [
+            region_filter,
+            new_temporal_filter,
+        ]
+
+    def test_replaces_temporal_xy_binding_when_subject_changes(self) -> None:
+        """A temporal XY binding does not survive rebinding to a new 
subject."""
+        previous_binding = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "event_time",
+        }
+        new_binding = {
+            **previous_binding,
+            "subject": "created_at",
+        }
+        new_form_data = {
+            "adhoc_filters": [new_binding],
+            "_mcp_dashboard_time_filter_subject": "created_at",
+        }
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {
+                "adhoc_filters": [previous_binding],
+                "_mcp_dashboard_time_filter_subject": "event_time",
+            },
+        )
+
+        assert new_form_data["adhoc_filters"] == [new_binding]
+
+    def test_replaces_big_number_fallback_binding_when_subject_changes(self) 
-> None:
+        """A Big Number fallback binding is replaced by a selected subject."""
+        dataset = Mock(
+            main_dttm_col=None,
+            columns=[Mock(column_name="order_date")],
+        )
+        config = BigNumberChartConfig(
+            chart_type="big_number",
+            metric=ColumnRef(name="revenue", aggregate="SUM"),
+        )
+        rebound_config = config.model_copy(update={"temporal_column": 
"created_at"})
+
+        with (
+            patch(
+                "superset.daos.dataset.DatasetDAO.find_by_id_or_uuid",
+                return_value=dataset,
+            ),
+            patch(
+                
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
+                return_value=True,
+            ),
+        ):
+            previous_form_data = map_big_number_config(config, dataset_id=42)
+            new_form_data = map_big_number_config(rebound_config, 
dataset_id=42)
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            previous_form_data,
+        )
+
+        assert previous_form_data["_mcp_dashboard_time_filter_subject"] == 
"order_date"
+        assert new_form_data["_mcp_dashboard_time_filter_subject"] == 
"created_at"
+        assert [filter_["subject"] for filter_ in 
new_form_data["adhoc_filters"]] == [
+            "created_at"
+        ]
+
+    def test_removes_cached_temporal_filter_without_new_binding(self) -> None:
+        """A mapping without a temporal subject drops the cached binding."""
+        region_filter = {
+            "clause": "WHERE",
+            "comparator": "North",
+            "expressionType": "SIMPLE",
+            "operator": "==",
+            "subject": "region",
+        }
+        previous_temporal_filter = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+        new_form_data: dict[str, Any] = {}
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {
+                "adhoc_filters": [region_filter, previous_temporal_filter],
+                "_mcp_dashboard_time_filter_subject": "ds",
+            },
+        )
+
+        assert new_form_data["adhoc_filters"] == [region_filter]
+
+    def test_preserves_user_temporal_filter_on_generated_subject(self) -> None:
+        """A user-authored range on the binding subject is not generated 
state."""
+        generated_binding = {
+            "clause": "WHERE",
+            "comparator": "No filter",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+        user_filter = {
+            "clause": "WHERE",
+            "comparator": "Last month",
+            "expressionType": "SIMPLE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+        }
+
+        new_form_data: dict[str, Any] = {}
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {
+                "adhoc_filters": [generated_binding, user_filter],
+                "_mcp_dashboard_time_filter_subject": "ds",
+            },
+        )
+
+        assert new_form_data["adhoc_filters"] == [user_filter]
+
+    def test_rebinding_preserves_unrelated_cached_temporal_filter(self) -> 
None:
+        """Only the generated binding is replaced; user filters retain 
provenance."""
+        previous_binding = {
+            "expressionType": "SIMPLE",
+            "clause": "WHERE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "ds",
+            "comparator": "No filter",
+        }
+        unrelated_filter = {
+            "expressionType": "SIMPLE",
+            "clause": "WHERE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "processed_at",
+            "comparator": "Last year",
+        }
+        new_binding = {
+            "expressionType": "SIMPLE",
+            "clause": "WHERE",
+            "operator": "TEMPORAL_RANGE",
+            "subject": "created_at",
+            "comparator": "No filter",
+        }
+        new_form_data = {
+            "adhoc_filters": [new_binding],
+            "_mcp_dashboard_time_filter_subject": "created_at",
+        }
+
+        preserve_previous_adhoc_filters(
+            new_form_data,
+            {
+                "adhoc_filters": [previous_binding, unrelated_filter],
+                "_mcp_dashboard_time_filter_subject": "ds",
+            },
+        )
+
+        assert new_form_data["adhoc_filters"] == [unrelated_filter, 
new_binding]
+
     @patch.object(update_chart_preview_module, "validate_and_compile")
     @patch.object(update_chart_preview_module, "has_dataset_access", 
return_value=True)
     @patch("superset.daos.dataset.DatasetDAO.find_by_id")

Reply via email to