This is an automated email from the ASF dual-hosted git repository. sadpandajoe pushed a commit to branch fix-dataset-export-uuid-folders in repository https://gitbox.apache.org/repos/asf/superset.git
commit 602bd657bb3763b6b974f57fe4179e594cc2cd33 Author: sadpandajoe <[email protected]> AuthorDate: Thu Jul 23 23:48:45 2026 +0000 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 <[email protected]> --- superset/datasets/schemas.py | 2 + superset/models/helpers.py | 1 + tests/integration_tests/datasets/commands_tests.py | 13 +++ tests/unit_tests/datasets/commands/export_test.py | 35 +++++-- .../datasets/commands/importers/v1/import_test.py | 105 +++++++++++++++++++++ 5 files changed, 150 insertions(+), 6 deletions(-) diff --git a/superset/datasets/schemas.py b/superset/datasets/schemas.py index bb54c138c5c..aa30a8e37dd 100644 --- a/superset/datasets/schemas.py +++ b/superset/datasets/schemas.py @@ -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): diff --git a/superset/models/helpers.py b/superset/models/helpers.py index 1376a6bfbb6..e0d32159397 100644 --- a/superset/models/helpers.py +++ b/superset/models/helpers.py @@ -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) ], diff --git a/tests/integration_tests/datasets/commands_tests.py b/tests/integration_tests/datasets/commands_tests.py index 54b448142cf..83028212b09 100644 --- a/tests/integration_tests/datasets/commands_tests.py +++ b/tests/integration_tests/datasets/commands_tests.py @@ -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, diff --git a/tests/unit_tests/datasets/commands/export_test.py b/tests/unit_tests/datasets/commands/export_test.py index d7cbc9dd37c..129521069a0 100644 --- a/tests/unit_tests/datasets/commands/export_test.py +++ b/tests/unit_tests/datasets/commands/export_test.py @@ -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,7 +272,8 @@ columns: python_date_format: null datetime_format: null extra: null -- column_name: expenses + uuid: 00000000-0000-0000-0000-000000000007 +- column_name: revenue verbose_name: null is_dttm: false is_active: true @@ -265,7 +286,8 @@ columns: python_date_format: null datetime_format: null extra: null -- column_name: revenue + uuid: 00000000-0000-0000-0000-000000000008 +- column_name: expenses verbose_name: null is_dttm: false is_active: true @@ -278,6 +300,7 @@ columns: python_date_format: null datetime_format: null extra: null + uuid: 00000000-0000-0000-0000-000000000009 version: 1.0.0 database_uuid: {database.uuid} """, diff --git a/tests/unit_tests/datasets/commands/importers/v1/import_test.py b/tests/unit_tests/datasets/commands/importers/v1/import_test.py index d568bde8e62..32565259032 100644 --- a/tests/unit_tests/datasets/commands/importers/v1/import_test.py +++ b/tests/unit_tests/datasets/commands/importers/v1/import_test.py @@ -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.
