mirror of
https://github.com/apache/superset.git
synced 2026-06-09 17:49:26 +00:00
Compare commits
4 Commits
ci/cypress
...
tdd/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42648f7293 | ||
|
|
d861fe344f | ||
|
|
506db607b2 | ||
|
|
a5ef943592 |
@@ -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,166 @@ def append_charts(position: dict[str, Any], charts: set[Slice]) -> dict[str, Any
|
||||
return position
|
||||
|
||||
|
||||
# Bound the derived id to a signed 31-bit positive integer so it stays well
|
||||
# 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
|
||||
|
||||
# Map each chart's env-local integer id -> stable UUID-derived id.
|
||||
id_map: dict[int, int] = {}
|
||||
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")
|
||||
old_id = meta.get("chartId")
|
||||
if chart_uuid is None:
|
||||
continue
|
||||
try:
|
||||
new_id = stable_chart_id(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
|
||||
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]:
|
||||
try:
|
||||
return id_map.get(int(old_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def remap_ids(old_ids: Any) -> list[int]:
|
||||
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 +343,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
|
||||
|
||||
|
||||
@@ -256,6 +256,309 @@ def test_file_content_omits_roles_field_when_dashboard_has_no_roles():
|
||||
assert "roles" not in result
|
||||
|
||||
|
||||
def test_position_json_chart_id_leaks_env_local_integers():
|
||||
"""
|
||||
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. That integer is the database auto-increment primary key from
|
||||
the source environment. When the bundle is imported into a different
|
||||
environment the importer (``update_id_refs``) rewrites those IDs to the
|
||||
destination-env primary keys. A second export from the destination then
|
||||
serializes the new env-local integers — the same logical chart produces
|
||||
different ``chartId`` values in each environment.
|
||||
|
||||
This test asserts that two exports of the same logical dashboard are
|
||||
identical even when the underlying chart has a different integer primary key
|
||||
in each environment. ``meta.uuid`` is the stable identifier that should be
|
||||
used instead of ``meta.chartId``.
|
||||
|
||||
Fix target: ``superset/commands/dashboard/export.py`` (``append_charts``
|
||||
and ``_file_content``) — strip or UUID-replace ``chartId`` so the export
|
||||
format is environment-independent.
|
||||
"""
|
||||
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 REGRESSION: chartId must be stable (or absent) across envs. #
|
||||
# #
|
||||
# With the current (broken) implementation both assertions below #
|
||||
# fail: #
|
||||
# - first export emits chartId 392 (source-env integer) #
|
||||
# - second export emits chartId 1001 (destination-env integer) #
|
||||
# The fix should either omit chartId entirely or derive it from the #
|
||||
# UUID so that both exports agree. #
|
||||
# ------------------------------------------------------------------ #
|
||||
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 are leaking into the export format (issue #32972). "
|
||||
"The fix: strip chartId from exported position_json or replace it with a "
|
||||
"value derived from meta.uuid so the export is 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_stabilize_chart_ids_skips_invalid_uuid():
|
||||
"""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():
|
||||
"""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():
|
||||
"""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_file_content_missing_dataset_preserves_dataset_id():
|
||||
"""
|
||||
When DatasetDAO.find_by_id returns None for a display control target,
|
||||
|
||||
Reference in New Issue
Block a user