diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py index 73f7b5fc580..c76cddbb5bc 100644 --- a/superset/commands/dashboard/export.py +++ b/superset/commands/dashboard/export.py @@ -19,6 +19,7 @@ import logging import random import string +import uuid as uuid_module from typing import Any, Optional, Callable from collections.abc import Iterator @@ -105,6 +106,189 @@ def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any return position +# Bound the derived id to the largest positive signed 32-bit integer +# (2**31 - 1) so it stays within the range a database auto-increment primary +# key would occupy and never collides with the sign bit. The concrete value is +# irrelevant — only its stability across environments matters. +_STABLE_CHART_ID_MODULO = 2_147_483_647 + + +def stable_chart_id(chart_uuid: str) -> int: + """Derive a deterministic, environment-independent integer from a chart UUID. + + The dashboard export format historically embedded ``meta.chartId`` — the + source environment's auto-increment primary key — inside every ``CHART-*`` + position node (issue #32972). That integer differs between environments, so + re-exporting an imported dashboard produced a different bundle for the same + logical content. Deriving the id from the (stable) UUID instead makes the + export reproducible while still giving the importer an integer it can use to + rewire the legacy, integer-keyed metadata references back to local IDs. + """ + return (uuid_module.UUID(chart_uuid).int % _STABLE_CHART_ID_MODULO) + 1 + + +def _stabilize_chart_ids(payload: dict[str, Any]) -> None: + """Replace env-local integer chart IDs in ``payload`` with UUID-derived ones. + + Rewrites ``meta.chartId`` in every ``CHART`` position node to a value derived + from ``meta.uuid`` and remaps the legacy, integer-keyed metadata references + (filter scopes, default filters, expanded slices, native filter scopes, and + cross-filter/chart configuration) accordingly so the bundle stays internally + consistent and the import-side id remap resolves against the same IDs. + Mappings are applied defensively — a reference whose source id is unknown is + dropped rather than raising, and a node with a malformed ``meta.uuid`` is + skipped — so a partially corrupt position never aborts the export. See + ``stable_chart_id`` and issue #32972 for the motivation. + """ + position = payload.get("position") + if not isinstance(position, dict): + return + + # Collect each chart node with its stable UUID-derived id. Nodes are + # processed in UUID order so that, on the (astronomically unlikely) event + # that two UUIDs reduce to the same integer, the deterministic collision + # resolution below assigns the same ids regardless of dict iteration order + # or environment. + chart_nodes: list[tuple[str, Any, dict[str, Any]]] = [] + for node in position.values(): + if ( + isinstance(node, dict) + and node.get("type") == "CHART" + and isinstance(node.get("meta"), dict) + ): + meta = node["meta"] + chart_uuid = meta.get("uuid") + if chart_uuid is None: + continue + try: + uuid_module.UUID(str(chart_uuid)) + except ValueError: + # A malformed ``meta.uuid`` (corrupt position_json) must not + # abort the whole export — skip stabilizing this single node + # and leave its existing chartId untouched. + logger.warning( + "Skipping chart id stabilization for invalid uuid %r", + chart_uuid, + ) + continue + chart_nodes.append((str(chart_uuid), meta.get("chartId"), meta)) + + # Map each chart's env-local integer id -> stable UUID-derived id. Since + # ``stable_chart_id`` reduces a UUID into a finite integer space, two + # distinct charts could in principle collide; if so, probe forward + # deterministically so every chart keeps a distinct id and the metadata + # remaps below never silently overwrite one another. + id_map: dict[int, int] = {} + assigned: set[int] = set() + for chart_uuid, old_id, meta in sorted(chart_nodes, key=lambda item: item[0]): + new_id = stable_chart_id(chart_uuid) + while new_id in assigned: + new_id = (new_id % _STABLE_CHART_ID_MODULO) + 1 + assigned.add(new_id) + if isinstance(old_id, int): + id_map[old_id] = new_id + meta["chartId"] = new_id + + if not id_map: + return + + metadata = payload.get("metadata") + if not isinstance(metadata, dict): + return + + def remap_id(old_id: Any) -> Optional[int]: + """Map a single legacy chart id to its stabilized id, or ``None``. + + Returns ``None`` when the id is unknown or not coercible to ``int`` so + callers can drop unresolved references rather than raising. + """ + try: + return id_map.get(int(old_id)) + except (TypeError, ValueError): + return None + + def remap_ids(old_ids: Any) -> list[int]: + """Remap a collection of legacy chart ids, dropping unresolved entries.""" + return [ + new_id for old_id in old_ids if (new_id := remap_id(old_id)) is not None + ] + + if isinstance(metadata.get("timed_refresh_immune_slices"), list): + metadata["timed_refresh_immune_slices"] = remap_ids( + metadata["timed_refresh_immune_slices"] + ) + + if isinstance(metadata.get("filter_scopes"), dict): + metadata["filter_scopes"] = { + str(new_key): columns + for old_key, columns in metadata["filter_scopes"].items() + if (new_key := remap_id(old_key)) is not None + } + for columns in metadata["filter_scopes"].values(): + if not isinstance(columns, dict): + continue + for attributes in columns.values(): + if isinstance(attributes, dict) and isinstance( + attributes.get("immune"), list + ): + attributes["immune"] = remap_ids(attributes["immune"]) + + if isinstance(metadata.get("expanded_slices"), dict): + metadata["expanded_slices"] = { + str(new_key): value + for old_key, value in metadata["expanded_slices"].items() + if (new_key := remap_id(old_key)) is not None + } + + if metadata.get("default_filters"): + try: + default_filters = json.loads(metadata["default_filters"]) + except (TypeError, json.JSONDecodeError): + default_filters = None + if isinstance(default_filters, dict): + metadata["default_filters"] = json.dumps( + { + str(new_key): value + for old_key, value in default_filters.items() + if (new_key := remap_id(old_key)) is not None + } + ) + + def remap_scope(container: Any) -> None: + """Remap ``scope.excluded`` and ``chartsInScope`` of a filter container. + + Shared by native filters and (global) cross-filter configuration, which + both denormalize the charts a filter applies to into these two lists. + """ + if not isinstance(container, dict): + return + scope = container.get("scope") + if isinstance(scope, dict) and isinstance(scope.get("excluded"), list): + scope["excluded"] = remap_ids(scope["excluded"]) + if isinstance(container.get("chartsInScope"), list): + container["chartsInScope"] = remap_ids(container["chartsInScope"]) + + if isinstance(metadata.get("native_filter_configuration"), list): + for native_filter in metadata["native_filter_configuration"]: + remap_scope(native_filter) + + if isinstance(metadata.get("global_chart_configuration"), dict): + remap_scope(metadata["global_chart_configuration"]) + + if isinstance(metadata.get("chart_configuration"), dict): + new_chart_configuration: dict[str, Any] = {} + for old_key, chart_config in metadata["chart_configuration"].items(): + new_key = remap_id(old_key) + if new_key is None: + continue + if isinstance(chart_config, dict): + if isinstance(chart_config.get("id"), int): + chart_config["id"] = new_key + remap_scope(chart_config.get("crossFilters")) + new_chart_configuration[str(new_key)] = chart_config + metadata["chart_configuration"] = new_chart_configuration + + class ExportDashboardsCommand(ExportModelsCommand): dao = DashboardDAO not_found = DashboardNotFoundError @@ -182,6 +366,11 @@ class ExportDashboardsCommand(ExportModelsCommand): if orphan_charts: payload["position"] = append_charts(payload["position"], orphan_charts) + # Strip env-local integer chart IDs in favor of UUID-derived ones so the + # export is reproducible across environments (issue #32972). Must run + # after orphan charts are appended so their nodes are stabilized too. + _stabilize_chart_ids(payload) + # Add theme UUID for proper cross-system imports payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None diff --git a/tests/unit_tests/commands/dashboard/export_test.py b/tests/unit_tests/commands/dashboard/export_test.py index c739ed70197..e9063a4cd5a 100644 --- a/tests/unit_tests/commands/dashboard/export_test.py +++ b/tests/unit_tests/commands/dashboard/export_test.py @@ -256,7 +256,488 @@ def test_file_content_omits_roles_field_when_dashboard_has_no_roles(): assert "roles" not in result -def test_file_content_missing_dataset_preserves_dataset_id(): +def test_position_json_chart_id_is_stable_across_environments() -> None: + """ + Regression for #32972: dashboard export must produce stable output that does + not vary with env-local integer chartIds. + + The export format includes a ``meta.chartId`` field inside each ``CHART-*`` + position entry. Historically that integer was the database auto-increment + primary key from the source environment. When a bundle is imported into a + different environment the importer (``update_id_refs``) rewrites those IDs to + the destination-env primary keys, so a second export from the destination + would serialize the new env-local integers — the same logical chart produced + different ``chartId`` values in each environment. + + The exporter now derives ``meta.chartId`` from the (stable) ``meta.uuid`` + instead, so this test asserts that two exports of the same logical dashboard + agree on ``chartId`` even when the underlying chart has a different integer + primary key in each environment. + """ + from superset.commands.dashboard.export import ExportDashboardsCommand + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + + # ------------------------------------------------------------------ # + # Source environment: chart has auto-increment id = 392 # + # ------------------------------------------------------------------ # + source_position = { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, + "GRID_ID": { + "children": ["CHART-srcAAA"], + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "type": "GRID", + }, + "HEADER_ID": { + "id": "HEADER_ID", + "meta": {"text": "My Dashboard"}, + "type": "HEADER", + }, + "CHART-srcAAA": { + "children": [], + "id": "CHART-srcAAA", + "meta": { + "chartId": 392, # source-env integer primary key + "height": 20, + "sliceName": "My Wonderful Chart", + "uuid": chart_uuid, + "width": 4, + }, + "parents": ["ROOT_ID", "GRID_ID"], + "type": "CHART", + }, + } + + src_dashboard = MagicMock() + src_dashboard.dashboard_title = "My Dashboard" + src_dashboard.theme = None + src_dashboard.slices = [] + src_dashboard.tags = [] + src_dashboard.roles = [] + src_dashboard.export_to_dict.return_value = { + "position_json": json.dumps(source_position), + "json_metadata": json.dumps({"native_filter_configuration": []}), + } + + with patch( + "superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled", + return_value=False, + ): + first_export_content = ExportDashboardsCommand._file_content(src_dashboard) + + first_export = yaml.safe_load(first_export_content) + first_chart_positions = [ + node + for node in first_export["position"].values() + if isinstance(node, dict) and node.get("type") == "CHART" + ] + assert first_chart_positions, "Export must contain at least one CHART position node" + first_chart_meta = first_chart_positions[0]["meta"] + + # uuid must be present — it is the stable cross-environment identifier + assert first_chart_meta.get("uuid") == chart_uuid, ( + "meta.uuid must be present in exported position_json; " + "it is the stable identifier that survives re-import." + ) + + # ------------------------------------------------------------------ # + # Destination environment: same chart receives a different id = 1001 # + # (This is what happens after import — update_id_refs rewrites the # + # chartId field inside position_json to the destination-env integer.) # + # ------------------------------------------------------------------ # + dest_position = { + k: ( + { + **v, + "meta": { + **v["meta"], + "chartId": 1001, # destination-env integer primary key + }, + } + if isinstance(v, dict) + and v.get("type") == "CHART" + and v.get("meta", {}).get("uuid") == chart_uuid + else v + ) + for k, v in source_position.items() + } + + dest_dashboard = MagicMock() + dest_dashboard.dashboard_title = "My Dashboard" + dest_dashboard.theme = None + dest_dashboard.slices = [] + dest_dashboard.tags = [] + dest_dashboard.roles = [] + dest_dashboard.export_to_dict.return_value = { + "position_json": json.dumps(dest_position), + "json_metadata": json.dumps({"native_filter_configuration": []}), + } + + with patch( + "superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled", + return_value=False, + ): + second_export_content = ExportDashboardsCommand._file_content(dest_dashboard) + + second_export = yaml.safe_load(second_export_content) + second_chart_positions = [ + node + for node in second_export["position"].values() + if isinstance(node, dict) and node.get("type") == "CHART" + ] + assert second_chart_positions, "Second export must contain at least one CHART node" + second_chart_meta = second_chart_positions[0]["meta"] + + # uuid must survive the re-import round-trip unchanged + assert second_chart_meta.get("uuid") == chart_uuid, ( + "meta.uuid must be stable across export → import → re-export; " + "UUIDs are the cross-environment identity, not integer IDs." + ) + + # ------------------------------------------------------------------ # + # THE INVARIANT: chartId must be stable across environments. # + # # + # The source-env export and the destination-env export (whose # + # position_json carries a different env-local integer after import) # + # must agree on chartId, because it is derived from the stable # + # meta.uuid rather than the env-local primary key. # + # ------------------------------------------------------------------ # + first_chart_id = first_chart_meta.get("chartId") + second_chart_id = second_chart_meta.get("chartId") + + assert first_chart_id == second_chart_id, ( + f"meta.chartId must be stable across environments, but the source-env " + f"export produced chartId={first_chart_id!r} while the destination-env " + f"export (after re-import with remapped IDs) produced " + f"chartId={second_chart_id!r}. " + "Env-local integer IDs must not leak into the export format (issue " + "#32972); chartId is derived from meta.uuid so the export stays " + "environment-independent." + ) + + +def _export_with_chart( + chart_uuid: str, + chart_id: int, + json_metadata: dict[str, Any], +) -> dict[str, Any]: + """Export a single-chart dashboard and return the parsed YAML payload.""" + from superset.commands.dashboard.export import ExportDashboardsCommand + + position = { + "DASHBOARD_VERSION_KEY": "v2", + "ROOT_ID": {"children": ["GRID_ID"], "id": "ROOT_ID", "type": "ROOT"}, + "GRID_ID": { + "children": ["CHART-aaa"], + "id": "GRID_ID", + "parents": ["ROOT_ID"], + "type": "GRID", + }, + "CHART-aaa": { + "children": [], + "id": "CHART-aaa", + "meta": { + "chartId": chart_id, + "height": 20, + "sliceName": "Chart", + "uuid": chart_uuid, + "width": 4, + }, + "parents": ["ROOT_ID", "GRID_ID"], + "type": "CHART", + }, + } + + dashboard = MagicMock() + dashboard.dashboard_title = "Test Dashboard" + dashboard.theme = None + dashboard.slices = [] + dashboard.tags = [] + dashboard.roles = [] + dashboard.export_to_dict.return_value = { + "position_json": json.dumps(position), + "json_metadata": json.dumps(json_metadata), + } + + with patch( + "superset.commands.dashboard.export.feature_flag_manager.is_feature_enabled", + return_value=False, + ): + content = ExportDashboardsCommand._file_content(dashboard) + + return yaml.safe_load(content) + + +def test_orphan_chart_gets_uuid_derived_chart_id() -> None: + """ + A chart in ``model.slices`` that is NOT referenced in ``position_json`` is + appended via ``append_charts`` (which writes the env-local ``chart.id``). + ``_stabilize_chart_ids`` runs afterwards, so the appended node must end up + with a UUID-derived ``chartId`` rather than the env-local integer. + """ + from superset.commands.dashboard.export import ( + ExportDashboardsCommand, + stable_chart_id, + ) + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + env_local_id = 392 + + orphan = MagicMock() + orphan.id = env_local_id + orphan.uuid = chart_uuid + orphan.slice_name = "Orphan Chart" + + # position_json references no charts, so ``orphan`` is appended as an orphan. + mock_dashboard = _make_mock_dashboard({"native_filter_configuration": []}) + mock_dashboard.slices = [orphan] + + 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) + chart_nodes = [ + node + for node in result["position"].values() + if isinstance(node, dict) and node.get("type") == "CHART" + ] + assert chart_nodes, "Orphan chart must be appended to the position tree" + chart_meta = chart_nodes[0]["meta"] + assert chart_meta["uuid"] == chart_uuid + # The env-local id must have been stabilized away, not serialized verbatim. + assert chart_meta["chartId"] == stable_chart_id(chart_uuid) + assert chart_meta["chartId"] != env_local_id + + +def test_stabilize_chart_ids_skips_invalid_uuid() -> None: + """A malformed meta.uuid must not abort the whole dashboard export.""" + result = _export_with_chart( + "not-a-valid-uuid", + 392, + {"native_filter_configuration": []}, + ) + chart_nodes = [ + node + for node in result["position"].values() + if isinstance(node, dict) and node.get("type") == "CHART" + ] + # Export still succeeds; the unstabilizable node keeps its original chartId. + assert chart_nodes + assert chart_nodes[0]["meta"]["chartId"] == 392 + + +def test_stabilize_chart_ids_remaps_native_filter_scope() -> None: + """Native filter scope.excluded / chartsInScope must track the stabilized id.""" + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [ + { + "id": "NATIVE_FILTER-1", + "scope": {"rootPath": ["ROOT_ID"], "excluded": [392]}, + "chartsInScope": [392], + } + ] + }, + ) + + native_filter = result["metadata"]["native_filter_configuration"][0] + assert native_filter["scope"]["excluded"] == [new_id] + assert native_filter["chartsInScope"] == [new_id] + + +def test_stabilize_chart_ids_remaps_cross_filter_configuration() -> None: + """global_chart_configuration and chart_configuration must be remapped.""" + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [], + "global_chart_configuration": { + "scope": {"rootPath": ["ROOT_ID"], "excluded": [392]}, + "chartsInScope": [392], + }, + "chart_configuration": { + "392": { + "id": 392, + "crossFilters": { + "scope": {"rootPath": ["ROOT_ID"], "excluded": [392]}, + "chartsInScope": [392], + }, + } + }, + }, + ) + + metadata = result["metadata"] + assert metadata["global_chart_configuration"]["scope"]["excluded"] == [new_id] + assert metadata["global_chart_configuration"]["chartsInScope"] == [new_id] + + # chart_configuration is re-keyed and its inner id / scopes are remapped. + assert str(new_id) in metadata["chart_configuration"] + chart_config = metadata["chart_configuration"][str(new_id)] + assert chart_config["id"] == new_id + assert chart_config["crossFilters"]["scope"]["excluded"] == [new_id] + assert chart_config["crossFilters"]["chartsInScope"] == [new_id] + + +def test_stable_chart_id_is_deterministic_and_in_range() -> None: + """ + stable_chart_id must derive a stable, environment-independent integer from a + chart UUID. The same UUID always yields the same id, and the id stays within + the positive signed 32-bit range so it can stand in for a database + auto-increment primary key without colliding with the sign bit. + """ + from superset.commands.dashboard.export import ( + _STABLE_CHART_ID_MODULO, + stable_chart_id, + ) + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + + # Deterministic: repeated derivations of the same UUID agree. + assert stable_chart_id(chart_uuid) == stable_chart_id(chart_uuid) + + # Distinct UUIDs map to distinct ids (no accidental collapse). + other_uuid = "00000000-0000-4000-8000-000000000001" + assert stable_chart_id(chart_uuid) != stable_chart_id(other_uuid) + + # Bounded to [1, _STABLE_CHART_ID_MODULO] for any UUID, including one whose + # high bits are all set (boundary case). Static inputs keep the test + # deterministic. + high_bits_uuid = "ffffffff-ffff-4fff-bfff-ffffffffffff" + for candidate in (chart_uuid, other_uuid, high_bits_uuid): + derived = stable_chart_id(candidate) + assert 1 <= derived <= _STABLE_CHART_ID_MODULO + + +def test_stabilize_chart_ids_remaps_default_filters() -> None: + """ + default_filters is a JSON *string* keyed by env-local chart id. The exporter + must parse it, remap the top-level keys to the stabilized ids, and re-emit it + as a JSON string so the bundle never leaks the source-env integer. + """ + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [], + "default_filters": json.dumps({"392": {"__time_range": "No filter"}}), + }, + ) + + # default_filters round-trips as a JSON string keyed by the stabilized id. + default_filters = json.loads(result["metadata"]["default_filters"]) + assert str(new_id) in default_filters + assert "392" not in default_filters + assert default_filters[str(new_id)] == {"__time_range": "No filter"} + + +def test_stabilize_chart_ids_remaps_timed_refresh_immune_slices() -> None: + """ + timed_refresh_immune_slices is a flat list of env-local chart ids. Each entry + must be remapped to the stabilized id so the immune list keeps pointing at the + same logical charts after a cross-environment round-trip. + """ + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [], + "timed_refresh_immune_slices": [392], + }, + ) + + assert result["metadata"]["timed_refresh_immune_slices"] == [new_id] + + +def test_stabilize_chart_ids_remaps_filter_scopes_keys_and_immune() -> None: + """ + filter_scopes is a dict keyed by env-local chart id, whose values hold nested + per-column ``immune`` lists of chart ids. The exporter must remap BOTH the + top-level keys AND the nested immune arrays to the stabilized ids. + """ + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [], + "filter_scopes": { + "392": { + "region": { + "scope": ["ROOT_ID"], + "immune": [392], + } + } + }, + }, + ) + + filter_scopes = result["metadata"]["filter_scopes"] + # Top-level key remapped to the stabilized id (and the old key is gone). + assert str(new_id) in filter_scopes + assert "392" not in filter_scopes + # Nested immune array remapped too. + assert filter_scopes[str(new_id)]["region"]["immune"] == [new_id] + + +def test_stabilize_chart_ids_remaps_expanded_slices() -> None: + """ + expanded_slices is a dict keyed by env-local chart id. The exporter must + re-key it to the stabilized ids while preserving the values. + """ + from superset.commands.dashboard.export import stable_chart_id + + chart_uuid = "812bc377-ac09-475a-8d34-a63f7f087bd7" + new_id = stable_chart_id(chart_uuid) + + result = _export_with_chart( + chart_uuid, + 392, + { + "native_filter_configuration": [], + "expanded_slices": {"392": True}, + }, + ) + + expanded_slices = result["metadata"]["expanded_slices"] + assert str(new_id) in expanded_slices + assert "392" not in expanded_slices + assert expanded_slices[str(new_id)] is True + + +def test_file_content_missing_dataset_preserves_dataset_id() -> None: """ When DatasetDAO.find_by_id returns None for a display control target, datasetId is preserved (dual-write: it was never popped) and no @@ -292,3 +773,46 @@ def test_file_content_missing_dataset_preserves_dataset_id(): target = result["metadata"]["chart_customization_config"][0]["targets"][0] assert target["datasetId"] == 9999 assert "datasetUuid" not in target + + +def test_stabilize_chart_ids_resolves_id_collisions() -> None: + """ + Two charts whose UUIDs reduce to the same derived id must still get distinct + stabilized ids, so the chart-keyed metadata remaps (filter_scopes, + chart_configuration, …) never silently overwrite one another. The forced + collision is simulated by stubbing ``stable_chart_id`` to a constant. + """ + from superset.commands.dashboard.export import _stabilize_chart_ids + + uuid_a = "00000000-0000-4000-8000-00000000000a" + uuid_b = "00000000-0000-4000-8000-00000000000b" + payload: dict[str, Any] = { + "position": { + "CHART-a": { + "type": "CHART", + "meta": {"chartId": 1, "uuid": uuid_a}, + }, + "CHART-b": { + "type": "CHART", + "meta": {"chartId": 2, "uuid": uuid_b}, + }, + }, + "metadata": { + "expanded_slices": {"1": True, "2": False}, + }, + } + + with patch( + "superset.commands.dashboard.export.stable_chart_id", + return_value=100, + ): + _stabilize_chart_ids(payload) + + id_a = payload["position"]["CHART-a"]["meta"]["chartId"] + id_b = payload["position"]["CHART-b"]["meta"]["chartId"] + # Distinct ids despite the collision; one keeps the derived value, the other + # is deterministically probed forward. + assert id_a != id_b + assert {id_a, id_b} == {100, 101} + # Neither expanded_slices entry was dropped by a key clash. + assert set(payload["metadata"]["expanded_slices"]) == {str(id_a), str(id_b)}