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

eschutho 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 144dae7c43a fix(dashboard): use datasetUuid instead of datasetId in 
display controls export/import (SC-104655) (#40008)
144dae7c43a is described below

commit 144dae7c43ae3c080511b5273952f6eccfb1b4e4
Author: Mafi <[email protected]>
AuthorDate: Fri May 15 03:18:57 2026 +1000

    fix(dashboard): use datasetUuid instead of datasetId in display controls 
export/import (SC-104655) (#40008)
    
    Co-authored-by: Matt Fitzgerald <[email protected]>
    Co-authored-by: Claude Sonnet 4.6 <[email protected]>
---
 superset/commands/dashboard/export.py              |  32 +++
 superset/commands/dashboard/importers/v1/utils.py  |  27 +++
 tests/unit_tests/commands/dashboard/export_test.py | 226 +++++++++++++++++++++
 .../dashboards/commands/importers/v1/utils_test.py | 137 +++++++++++++
 4 files changed, 422 insertions(+)

diff --git a/superset/commands/dashboard/export.py 
b/superset/commands/dashboard/export.py
index f3f99519ab2..e31c4e91c4b 100644
--- a/superset/commands/dashboard/export.py
+++ b/superset/commands/dashboard/export.py
@@ -146,6 +146,27 @@ class ExportDashboardsCommand(ExportModelsCommand):
                     if dataset:
                         target["datasetUuid"] = str(dataset.uuid)
 
+        # Replace display control dataset references with uuid.
+        # datasetId is intentionally preserved alongside datasetUuid so that
+        # bundles remain importable by older versions that do not yet 
understand
+        # datasetUuid for display-control targets.
+        for customization in (
+            payload.get("metadata", {}).get("chart_customization_config") or []
+        ):
+            for target in customization.get("targets") or []:
+                dataset_id = target.get("datasetId")
+                if dataset_id is not None:
+                    dataset = DatasetDAO.find_by_id(dataset_id)
+                    if dataset:
+                        target["datasetUuid"] = str(dataset.uuid)
+                    else:
+                        logger.warning(
+                            "Dashboard '%s': display control target references 
"
+                            "missing dataset %s; datasetUuid will not be set",
+                            model.dashboard_title,
+                            dataset_id,
+                        )
+
         # the mapping between dashboard -> charts is inferred from the position
         # attribute, so if it's not present we need to add a default config
         if not payload.get("position"):
@@ -230,3 +251,14 @@ class ExportDashboardsCommand(ExportModelsCommand):
                         dataset = DatasetDAO.find_by_id(dataset_id)
                         if dataset:
                             yield from 
ExportDatasetsCommand([dataset_id]).run()
+
+            # Export datasets referenced by display controls
+            for customization in (
+                payload.get("metadata", {}).get("chart_customization_config") 
or []
+            ):
+                for target in customization.get("targets") or []:
+                    dataset_id = target.get("datasetId")
+                    if dataset_id is not None:
+                        dataset = DatasetDAO.find_by_id(dataset_id)
+                        if dataset:
+                            yield from 
ExportDatasetsCommand([dataset_id]).run()
diff --git a/superset/commands/dashboard/importers/v1/utils.py 
b/superset/commands/dashboard/importers/v1/utils.py
index c506db72cb6..2d9b6bf1188 100644
--- a/superset/commands/dashboard/importers/v1/utils.py
+++ b/superset/commands/dashboard/importers/v1/utils.py
@@ -42,6 +42,11 @@ def find_native_filter_datasets(metadata: dict[str, Any]) -> 
set[str]:
             dataset_uuid = target.get("datasetUuid")
             if dataset_uuid:
                 uuids.add(dataset_uuid)
+    for customization in metadata.get("chart_customization_config") or []:
+        for target in customization.get("targets") or []:
+            dataset_uuid = target.get("datasetUuid")
+            if dataset_uuid:
+                uuids.add(dataset_uuid)
     return uuids
 
 
@@ -139,6 +144,28 @@ def update_id_refs(  # pylint: disable=too-many-locals  # 
noqa: C901
             native_filter["scope"]["excluded"] = [
                 id_map[old_id] for old_id in scope_excluded if old_id in id_map
             ]
+
+    # fix display control dataset references
+    for customization in (
+        fixed.get("metadata", {}).get("chart_customization_config") or []
+    ):
+        for target in customization.get("targets") or []:
+            dataset_uuid = target.pop("datasetUuid", None)
+            if dataset_uuid:
+                info = dataset_info.get(dataset_uuid)
+                if info:
+                    target["datasetId"] = info["datasource_id"]
+                else:
+                    # UUID present but unresolvable — remove stale integer ID
+                    # so the control fails visibly rather than binding to
+                    # whatever dataset happens to own that ID in this 
environment
+                    target.pop("datasetId", None)
+                    logger.warning(
+                        "Display control target references unknown dataset 
UUID %s; "
+                        "datasetId will not be restored",
+                        dataset_uuid,
+                    )
+
     fixed = update_cross_filter_scoping(fixed, id_map)
     return fixed
 
diff --git a/tests/unit_tests/commands/dashboard/export_test.py 
b/tests/unit_tests/commands/dashboard/export_test.py
new file mode 100644
index 00000000000..16fbb393b5c
--- /dev/null
+++ b/tests/unit_tests/commands/dashboard/export_test.py
@@ -0,0 +1,226 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import uuid
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import yaml
+
+from superset.utils import json
+
+
+def _make_mock_dashboard(json_metadata: dict[str, Any]) -> MagicMock:
+    dashboard = MagicMock()
+    dashboard.dashboard_title = "Test Dashboard"
+    dashboard.theme = None
+    dashboard.slices = []
+    dashboard.tags = []
+    dashboard.export_to_dict.return_value = {
+        "position_json": json.dumps(
+            {
+                "DASHBOARD_VERSION_KEY": "v2",
+                "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": 
"ROOT"},
+                "GRID_ID": {
+                    "children": [],
+                    "id": "GRID_ID",
+                    "parents": ["ROOT_ID"],
+                    "type": "GRID",
+                },
+                "HEADER_ID": {
+                    "id": "HEADER_ID",
+                    "meta": {"text": "Test Dashboard"},
+                    "type": "HEADER",
+                },
+            }
+        ),
+        "json_metadata": json.dumps(json_metadata),
+    }
+    return dashboard
+
+
+def test_file_content_replaces_dataset_id_with_uuid_in_display_controls():
+    """
+    _file_content must replace datasetId with datasetUuid in 
chart_customization_config
+    targets, mirroring what it already does for native_filter_configuration.
+    """
+    from superset.commands.dashboard.export import ExportDashboardsCommand
+
+    dataset_uuid = str(uuid.uuid4())
+
+    mock_dashboard = _make_mock_dashboard(
+        {
+            "native_filter_configuration": [],
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-abc",
+                    "type": "CHART_CUSTOMIZATION",
+                    "targets": [{"datasetId": 99, "column": {"name": "col"}}],
+                },
+                {
+                    "id": "CUSTOMIZATION-divider",
+                    "type": "CHART_CUSTOMIZATION_DIVIDER",
+                    "targets": [],
+                },
+            ],
+        }
+    )
+
+    mock_dataset = MagicMock()
+    mock_dataset.uuid = dataset_uuid
+
+    with (
+        patch(
+            "superset.commands.dashboard.export.DatasetDAO.find_by_id",
+            return_value=mock_dataset,
+        ),
+        patch(
+            
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
+            return_value=False,
+        ),
+    ):
+        content = ExportDashboardsCommand._file_content(mock_dashboard)
+
+    result = yaml.safe_load(content)
+    customizations = result["metadata"]["chart_customization_config"]
+
+    # datasetUuid must be added; datasetId preserved for backward compat
+    target = customizations[0]["targets"][0]
+    assert target["datasetUuid"] == dataset_uuid
+    assert target["datasetId"] == 99
+
+    # Dividers with no targets must be unaffected
+    assert customizations[1]["targets"] == []
+
+
+def test_export_yields_dataset_files_for_display_controls():
+    """
+    _export must yield dataset files for datasets referenced by display 
controls.
+
+    The _export generator has a second pass over json_metadata (separate from
+    _file_content) whose job is to emit dataset YAML files into the bundle.
+    Without this, the round-trip fails: the UUID is in the dashboard YAML but
+    the dataset file is absent from the ZIP.
+    """
+    from superset.commands.dashboard.export import ExportDashboardsCommand
+
+    dataset_id = 42
+    mock_dashboard = _make_mock_dashboard(
+        {
+            "native_filter_configuration": [],
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-abc",
+                    "type": "CHART_CUSTOMIZATION",
+                    "targets": [{"datasetId": dataset_id}],
+                },
+            ],
+        }
+    )
+
+    mock_dataset = MagicMock()
+    sentinel_file = ("datasets/my_dataset.yaml", lambda: "dataset_content")
+    mock_datasets_cmd = MagicMock()
+    mock_datasets_cmd.run.return_value = iter([sentinel_file])
+
+    with (
+        patch(
+            "superset.commands.dashboard.export.DatasetDAO.find_by_id",
+            return_value=mock_dataset,
+        ),
+        patch(
+            "superset.commands.dashboard.export.ExportDatasetsCommand",
+            return_value=mock_datasets_cmd,
+        ) as mock_datasets_cls,
+        patch(
+            "superset.commands.dashboard.export.ExportChartsCommand"
+        ) as mock_charts_cls,
+        patch(
+            
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
+            return_value=False,
+        ),
+    ):
+        mock_charts_cls.return_value.run.return_value = iter([])
+        results = list(ExportDashboardsCommand._export(mock_dashboard))
+
+    mock_datasets_cls.assert_called_once_with([dataset_id])
+    mock_datasets_cmd.run.assert_called_once()
+    filenames = [name for name, _ in results]
+    assert "datasets/my_dataset.yaml" in filenames
+
+
+def test_file_content_null_chart_customization_config_does_not_raise():
+    """
+    When chart_customization_config is explicitly null in metadata,
+    _file_content must not raise — the `or []` guard handles it.
+    """
+    from superset.commands.dashboard.export import ExportDashboardsCommand
+
+    mock_dashboard = _make_mock_dashboard(
+        {
+            "native_filter_configuration": [],
+            "chart_customization_config": None,
+        }
+    )
+
+    with patch(
+        
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
+        return_value=False,
+    ):
+        content = ExportDashboardsCommand._file_content(mock_dashboard)
+
+    result = yaml.safe_load(content)
+    assert result["metadata"]["chart_customization_config"] is None
+
+
+def test_file_content_missing_dataset_preserves_dataset_id():
+    """
+    When DatasetDAO.find_by_id returns None for a display control target,
+    datasetId is preserved (dual-write: it was never popped) and no
+    datasetUuid is added — the target is not silently emptied.
+    """
+    from superset.commands.dashboard.export import ExportDashboardsCommand
+
+    mock_dashboard = _make_mock_dashboard(
+        {
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-orphan",
+                    "type": "CHART_CUSTOMIZATION",
+                    "targets": [{"datasetId": 9999}],
+                },
+            ],
+        }
+    )
+
+    with (
+        patch(
+            "superset.commands.dashboard.export.DatasetDAO.find_by_id",
+            return_value=None,
+        ),
+        patch(
+            
"superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled",
+            return_value=False,
+        ),
+    ):
+        content = ExportDashboardsCommand._file_content(mock_dashboard)
+
+    result = yaml.safe_load(content)
+    target = result["metadata"]["chart_customization_config"][0]["targets"][0]
+    assert target["datasetId"] == 9999
+    assert "datasetUuid" not in target
diff --git a/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py 
b/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py
index 0edd659bb21..88dfa393c6f 100644
--- a/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py
+++ b/tests/unit_tests/dashboards/commands/importers/v1/utils_test.py
@@ -244,6 +244,143 @@ def 
test_update_id_refs_preserves_time_grains_in_native_filters():
     assert filter_config.get("filterType") == "filter_timegrain"
 
 
+def test_find_native_filter_datasets_includes_display_controls():
+    """
+    Test that find_native_filter_datasets also returns dataset UUIDs
+    from chart_customization_config (display controls).
+    """
+    from superset.commands.dashboard.importers.v1.utils import (
+        find_native_filter_datasets,
+    )
+
+    metadata = {
+        "native_filter_configuration": [
+            {"targets": [{"datasetUuid": "uuid-native-1"}]},
+        ],
+        "chart_customization_config": [
+            {"targets": [{"datasetUuid": "uuid-display-1"}]},
+            {"targets": [{"datasetUuid": "uuid-display-2"}]},
+            {"targets": []},
+        ],
+    }
+
+    uuids = find_native_filter_datasets(metadata)
+    assert uuids == {"uuid-native-1", "uuid-display-1", "uuid-display-2"}
+
+
+def test_update_id_refs_fixes_display_control_dataset_references():
+    """
+    Test that update_id_refs converts datasetUuid back to datasetId in
+    chart_customization_config (display controls) during import.
+    """
+    from superset.commands.dashboard.importers.v1.utils import update_id_refs
+
+    config: dict[str, Any] = {
+        "position": {
+            "CHART1": {
+                "id": "CHART1",
+                "meta": {"chartId": 101, "uuid": "uuid1"},
+                "type": "CHART",
+            },
+        },
+        "metadata": {
+            "native_filter_configuration": [],
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-abc",
+                    "type": "CHART_CUSTOMIZATION",
+                    # dual-write format: both fields present in exported bundle
+                    "targets": [
+                        {
+                            "datasetId": 99,
+                            "datasetUuid": "ds-uuid-1",
+                            "column": {"name": "col"},
+                        }
+                    ],
+                },
+                {
+                    "id": "CUSTOMIZATION-divider",
+                    "type": "CHART_CUSTOMIZATION_DIVIDER",
+                    "targets": [],
+                },
+            ],
+        },
+    }
+
+    chart_ids = {"uuid1": 1}
+    dataset_info: dict[str, dict[str, Any]] = {
+        "ds-uuid-1": {"datasource_id": 42},
+    }
+
+    fixed = update_id_refs(config, chart_ids, dataset_info)
+
+    customizations = fixed["metadata"]["chart_customization_config"]
+    target = customizations[0]["targets"][0]
+    assert target["datasetId"] == 42  # updated to destination-env ID
+    assert "datasetUuid" not in target  # consumed by import
+    assert customizations[1]["targets"] == []
+
+
+def test_update_id_refs_removes_stale_dataset_id_when_uuid_unresolvable():
+    """
+    When a target has both datasetId and datasetUuid but the UUID is absent
+    from dataset_info, the stale datasetId must also be removed. A visibly
+    broken control is safer than one silently bound to whatever dataset
+    happens to own that integer ID in the destination environment.
+    """
+    from superset.commands.dashboard.importers.v1.utils import update_id_refs
+
+    config: dict[str, Any] = {
+        "position": {},
+        "metadata": {
+            "native_filter_configuration": [],
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-abc",
+                    "type": "CHART_CUSTOMIZATION",
+                    "targets": [{"datasetId": 99, "datasetUuid": 
"uuid-missing"}],
+                },
+            ],
+        },
+    }
+
+    fixed = update_id_refs(config, {}, {})
+
+    target = fixed["metadata"]["chart_customization_config"][0]["targets"][0]
+    assert "datasetUuid" not in target
+    assert "datasetId" not in target
+
+
+def test_update_id_refs_skips_display_control_target_on_missing_uuid():
+    """
+    When a display control target's datasetUuid is absent from dataset_info
+    (e.g. a partially corrupt export bundle), update_id_refs skips the target
+    silently rather than raising KeyError — the datasetUuid is popped and no
+    datasetId is written, leaving the target without a dataset reference.
+    """
+    from superset.commands.dashboard.importers.v1.utils import update_id_refs
+
+    config: dict[str, Any] = {
+        "position": {},
+        "metadata": {
+            "native_filter_configuration": [],
+            "chart_customization_config": [
+                {
+                    "id": "CUSTOMIZATION-abc",
+                    "type": "CHART_CUSTOMIZATION",
+                    "targets": [{"datasetUuid": "uuid-missing-from-bundle"}],
+                },
+            ],
+        },
+    }
+
+    fixed = update_id_refs(config, {}, {})
+
+    target = fixed["metadata"]["chart_customization_config"][0]["targets"][0]
+    assert "datasetUuid" not in target
+    assert "datasetId" not in target
+
+
 def test_update_id_refs_handles_missing_time_grains():
     """
     Test backward compatibility when time_grains is not present.

Reply via email to