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.