Compare commits

...

1 Commits

Author SHA1 Message Date
sadpandajoe
602bd657bb fix(datasets): preserve metric/column uuids on dataset export
Dataset folders reference metrics and columns by UUID. On export the
recursive child serialization in ImportExportMixin.export_to_dict did not
forward export_uuids, so exported metrics/columns carried no uuid. On
import each child was recreated with a fresh random uuid while the folders
JSON still pointed at the originals, so the item could no longer be matched
to its custom folder and was re-homed to the default folder.

Forward export_uuids into the recursive child export and accept uuid in the
import column/metric schemas so the references round-trip intact. The
import layer already recreates children with a provided uuid. Only the two
dataset export paths (dataset and database exports) pass export_uuids=True,
so behavior is unchanged for callers using the default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 23:58:53 +00:00
5 changed files with 161 additions and 17 deletions

View File

@@ -276,6 +276,7 @@ class ImportV1ColumnSchema(Schema):
description = fields.String(allow_none=True)
python_date_format = fields.String(allow_none=True)
datetime_format = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
class ImportMetricCurrencySchema(Schema):
@@ -318,6 +319,7 @@ class ImportV1MetricSchema(Schema):
currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True)
extra = fields.Dict(allow_none=True)
warning_text = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
class ImportV1DatasetSchema(Schema):

View File

@@ -615,6 +615,7 @@ class ImportExportMixin(UUIDMixin):
recursive=recursive,
include_parent_ref=include_parent_ref,
include_defaults=include_defaults,
export_uuids=export_uuids,
)
for child in getattr(self, cld)
],

View File

@@ -95,6 +95,14 @@ class TestExportDatasetsCommand(SupersetTestCase):
type_map = {
column.column_name: str(column.type) for column in example_dataset.columns
}
# column/metric UUIDs are now exported so folder references survive an
# import; they are assigned dynamically, so build lookups by name.
column_uuid_map = {
column.column_name: str(column.uuid) for column in example_dataset.columns
}
metric_uuid_map = {
metric.metric_name: str(metric.uuid) for metric in example_dataset.metrics
}
assert metadata == {
"cache_timeout": None,
@@ -115,6 +123,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
"uuid": column_uuid_map["source"],
},
{
"column_name": "target",
@@ -127,6 +136,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"is_dttm": False,
"python_date_format": None,
"type": type_map["target"],
"uuid": column_uuid_map["target"],
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
@@ -145,6 +155,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
"uuid": column_uuid_map["value"],
},
],
"database_uuid": str(example_db.uuid),
@@ -165,6 +176,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"metric_type": "count",
"verbose_name": "COUNT(*)",
"warning_text": None,
"uuid": metric_uuid_map["count"],
},
{
"currency": None,
@@ -176,6 +188,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"metric_type": None,
"verbose_name": None,
"warning_text": None,
"uuid": metric_uuid_map["sum__value"],
},
],
"folders": None,

View File

@@ -41,10 +41,27 @@ def test_export(session: Session) -> None:
db.session.flush()
columns = [
TableColumn(column_name="ds", is_dttm=1, type="TIMESTAMP"),
TableColumn(column_name="user_id", type="INTEGER"),
TableColumn(column_name="revenue", type="INTEGER"),
TableColumn(column_name="expenses", type="INTEGER"),
TableColumn(
column_name="ds",
is_dttm=1,
type="TIMESTAMP",
uuid=UUID("00000000-0000-0000-0000-000000000006"),
),
TableColumn(
column_name="user_id",
type="INTEGER",
uuid=UUID("00000000-0000-0000-0000-000000000007"),
),
TableColumn(
column_name="revenue",
type="INTEGER",
uuid=UUID("00000000-0000-0000-0000-000000000008"),
),
TableColumn(
column_name="expenses",
type="INTEGER",
uuid=UUID("00000000-0000-0000-0000-000000000009"),
),
TableColumn(
column_name="profit",
type="INTEGER",
@@ -211,6 +228,7 @@ metrics:
extra:
warning_markdown: null
warning_text: null
uuid: 00000000-0000-0000-0000-000000000004
columns:
- column_name: profit
verbose_name: null
@@ -226,6 +244,7 @@ columns:
datetime_format: null
extra:
certified_by: User
uuid: 00000000-0000-0000-0000-000000000005
- column_name: ds
verbose_name: null
is_dttm: 1
@@ -239,6 +258,7 @@ columns:
python_date_format: null
datetime_format: null
extra: null
uuid: 00000000-0000-0000-0000-000000000006
- column_name: user_id
verbose_name: null
is_dttm: false
@@ -252,19 +272,7 @@ columns:
python_date_format: null
datetime_format: null
extra: null
- column_name: expenses
verbose_name: null
is_dttm: false
is_active: true
type: INTEGER
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
uuid: 00000000-0000-0000-0000-000000000007
- column_name: revenue
verbose_name: null
is_dttm: false
@@ -278,6 +286,21 @@ columns:
python_date_format: null
datetime_format: null
extra: null
uuid: 00000000-0000-0000-0000-000000000008
- column_name: expenses
verbose_name: null
is_dttm: false
is_active: true
type: INTEGER
advanced_data_type: null
groupby: true
filterable: true
expression: null
description: null
python_date_format: null
datetime_format: null
extra: null
uuid: 00000000-0000-0000-0000-000000000009
version: 1.0.0
database_uuid: {database.uuid}
""",

View File

@@ -26,6 +26,7 @@ from unittest.mock import Mock, patch
from urllib import request
import pytest
import yaml
from flask import current_app
from flask_appbuilder.security.sqla.models import Role, User
from pytest_mock import MockerFixture
@@ -253,6 +254,110 @@ def test_import_dataset(mocker: MockerFixture, session: Session) -> None:
assert sqla_table.database.id == database.id
def test_export_import_round_trip_preserves_metric_folder_membership(
mocker: MockerFixture, session: Session
) -> None:
"""
A metric (or column) assigned to a custom folder must stay in that folder
after the dataset is exported and imported into another workspace.
Folder leaves reference metrics/columns by UUID. If the export drops the
metric/column UUIDs, the importer recreates them with fresh random UUIDs
while the ``folders`` JSON still points at the originals — so the metric can
no longer be matched to its folder and is re-homed to the default folder.
This exercises the full export -> import round trip and asserts the
imported metric/column keep the UUIDs the folder leaves reference.
"""
from superset.commands.dataset.export import ExportDatasetsCommand
from superset.connectors.sqla.models import SqlMetric
mocker.patch.object(security_manager, "can_access", return_value=True)
engine = db.session.get_bind()
SqlaTable.metadata.create_all(engine) # pylint: disable=no-member
# --- source workspace: a dataset with a metric + column pinned to a custom
# folder, referenced by UUID ---
source_db = Database(database_name="source_db", sqlalchemy_uri="sqlite://")
db.session.add(source_db)
db.session.flush()
metric_uuid = uuid.UUID("00000000-0000-0000-0000-0000000000a1")
column_uuid = uuid.UUID("00000000-0000-0000-0000-0000000000b2")
folder_uuid = uuid.UUID("00000000-0000-0000-0000-0000000000c3")
sqla_table = SqlaTable(
table_name="my_table",
database=source_db,
columns=[
TableColumn(column_name="profit", type="INTEGER", uuid=column_uuid),
],
metrics=[
SqlMetric(metric_name="cnt", expression="COUNT(*)", uuid=metric_uuid),
],
folders=[
{
"uuid": str(folder_uuid),
"type": "folder",
"name": "Custom",
"children": [
{"uuid": str(metric_uuid)},
{"uuid": str(column_uuid)},
],
},
],
)
db.session.add(sqla_table)
db.session.flush()
# --- export (command-level YAML payload) ---
config = yaml.safe_load(ExportDatasetsCommand._file_content(sqla_table)) # pylint: disable=protected-access
# The exported metric/column must carry their UUIDs so the folder
# references survive the round trip.
assert config["metrics"][0].get("uuid") == str(metric_uuid)
assert any(col.get("uuid") == str(column_uuid) for col in config["columns"])
# The import schema must accept and preserve those UUIDs; without the schema
# fields it would reject them as unknown and the round trip would break.
loaded = ImportV1DatasetSchema().load(config)
assert loaded["metrics"][0]["uuid"] == metric_uuid
assert any(col["uuid"] == column_uuid for col in loaded["columns"])
# --- import into another workspace ---
# Model a separate workspace: drop the source dataset so its UUIDs are free
# (a fresh workspace has never seen them), then import against a fresh
# database and a brand-new dataset UUID so the importer creates the
# metric/column anew from the exported payload.
db.session.delete(sqla_table)
db.session.flush()
target_db = Database(database_name="target_db", sqlalchemy_uri="sqlite://")
db.session.add(target_db)
db.session.flush()
config["database_id"] = target_db.id
config["uuid"] = str(uuid.uuid4())
imported = import_dataset(config)
# The metric/column must be recreated with their original UUIDs so the
# folder leaves still resolve to them — i.e. they stay in the custom folder.
imported_metric = next(m for m in imported.metrics if m.metric_name == "cnt")
assert imported_metric.uuid == metric_uuid
imported_column = next(c for c in imported.columns if c.column_name == "profit")
assert imported_column.uuid == column_uuid
# Every UUID referenced by a folder leaf must exist among the imported
# metrics/columns; otherwise the item is orphaned back to the default folder.
folder_leaf_uuids = {
child["uuid"] for folder in imported.folders for child in folder["children"]
}
child_uuids = {str(m.uuid) for m in imported.metrics} | {
str(c.uuid) for c in imported.columns
}
assert folder_leaf_uuids <= child_uuids
def test_import_dataset_no_folder(mocker: MockerFixture, session: Session) -> None:
"""
Test importing a dataset that was exported without folders.