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.

Reply via email to