You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/07/24 17:22:44 UTC
(superset) branch fix-dataset-export-uuid-folders created (now 602bd657bb3)
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a change to branch fix-dataset-export-uuid-folders
in repository https://gitbox.apache.org/repos/asf/superset.git
at 602bd657bb3 fix(datasets): preserve metric/column uuids on dataset export
This branch includes the following new commits:
new 602bd657bb3 fix(datasets): preserve metric/column uuids on dataset export
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: fix(datasets): preserve metric/column uuids on dataset export
Posted by jl...@apache.org.
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 <jc...@gmail.com>
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 <no...@anthropic.com>
---
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.