You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/28 03:28:01 UTC

(superset) branch master updated: fix: Dashboard export with charts from multiple databases (#37120)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new b7301ac88a6 fix: Dashboard export with charts from multiple databases (#37120)
b7301ac88a6 is described below

commit b7301ac88a6dff6ad41eacd13b2f4588304fc880
Author: Milad Rashidikhah <mr...@gmail.com>
AuthorDate: Fri Aug 28 06:57:54 2026 +0330

    fix: Dashboard export with charts from multiple databases (#37120)
    
    Co-authored-by: rusackas <ev...@rusackas.com>
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 superset/commands/chart/export.py                  |  15 ++-
 superset/commands/dashboard/export.py              |  26 ++++--
 superset/commands/database/export.py               |   6 +-
 superset/commands/dataset/export.py                |  63 ++++++++-----
 superset/commands/export/models.py                 |  40 +++++---
 superset/commands/query/export.py                  |  42 +++++----
 superset/commands/tag/export.py                    |   4 +-
 superset/commands/theme/export.py                  |   6 +-
 .../integration_tests/dashboards/commands_tests.py | 104 +++++++++++++++++++++
 9 files changed, 240 insertions(+), 66 deletions(-)

diff --git a/superset/commands/chart/export.py b/superset/commands/chart/export.py
index 47ea7e67376..89a8a015b9f 100644
--- a/superset/commands/chart/export.py
+++ b/superset/commands/chart/export.py
@@ -91,8 +91,10 @@ class ExportChartsCommand(ExportModelsCommand):
     def enable_tag_export(cls) -> None:
         cls._include_tags = True
 
-    def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
-        yield from super().run()
+    def run(
+        self, seen: set[str] | None = None
+    ) -> Iterator[tuple[str, Callable[[], str]]]:
+        yield from super().run(seen=seen)
 
         # Tags are exported once for all requested charts (rather than per
         # chart in `_export`) so a multi-chart export doesn't lose tags to
@@ -108,12 +110,17 @@ class ExportChartsCommand(ExportModelsCommand):
 
     @staticmethod
     def _export(
-        model: Slice, export_related: bool = True
+        model: Slice, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided
+        if seen is None:
+            seen = set()
+
         yield (
             ExportChartsCommand._file_name(model),
             lambda: ExportChartsCommand._file_content(model),
         )
 
         if model.table and export_related:
-            yield from ExportDatasetsCommand([model.table.id]).run()
+            # Pass the shared seen set to the dataset export command
+            yield from ExportDatasetsCommand([model.table.id]).run(seen=seen)
diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py
index 9ea8e1ee900..2618889e20b 100644
--- a/superset/commands/dashboard/export.py
+++ b/superset/commands/dashboard/export.py
@@ -383,8 +383,12 @@ class ExportDashboardsCommand(ExportModelsCommand):
     @staticmethod
     # ruff: noqa: C901
     def _export(
-        model: Dashboard, export_related: bool = True
+        model: Dashboard, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided
+        if seen is None:
+            seen = set()
+
         yield (
             ExportDashboardsCommand._file_name(model),
             lambda: ExportDashboardsCommand._file_content(model),
@@ -395,8 +399,11 @@ class ExportDashboardsCommand(ExportModelsCommand):
             dashboard_ids = model.id
             command = ExportChartsCommand(chart_ids)
             command.disable_tag_export()
-            yield from command.run()
-            command.enable_tag_export()
+            try:
+                # Pass the shared seen set to the chart export command
+                yield from command.run(seen=seen)
+            finally:
+                command.enable_tag_export()
             if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
                 yield from ExportTagsCommand(
                     dashboard_ids=dashboard_ids, chart_ids=chart_ids
@@ -406,7 +413,8 @@ class ExportDashboardsCommand(ExportModelsCommand):
             if model.theme:
                 from superset.commands.theme.export import ExportThemesCommand
 
-                yield from ExportThemesCommand([model.theme.id]).run()
+                # Pass the shared seen set to the theme export command
+                yield from ExportThemesCommand([model.theme.id]).run(seen=seen)
 
         payload = model.export_to_dict(
             recursive=False,
@@ -435,7 +443,10 @@ class ExportDashboardsCommand(ExportModelsCommand):
                     if dataset_id is not None:
                         dataset = DatasetDAO.find_by_id(dataset_id)
                         if dataset:
-                            yield from ExportDatasetsCommand([dataset_id]).run()
+                            # Pass the shared seen set to the dataset export command
+                            yield from ExportDatasetsCommand([dataset_id]).run(
+                                seen=seen
+                            )
 
             # Export datasets referenced by display controls
             for customization in (
@@ -446,4 +457,7 @@ class ExportDashboardsCommand(ExportModelsCommand):
                     if dataset_id is not None:
                         dataset = DatasetDAO.find_by_id(dataset_id)
                         if dataset:
-                            yield from ExportDatasetsCommand([dataset_id]).run()
+                            # Pass the shared seen set to the dataset export command
+                            yield from ExportDatasetsCommand([dataset_id]).run(
+                                seen=seen
+                            )
diff --git a/superset/commands/database/export.py b/superset/commands/database/export.py
index 3ca1e74b1df..638746ae7bd 100644
--- a/superset/commands/database/export.py
+++ b/superset/commands/database/export.py
@@ -113,8 +113,12 @@ class ExportDatabasesCommand(ExportModelsCommand):
 
     @staticmethod
     def _export(
-        model: Database, export_related: bool = True
+        model: Database, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided
+        if seen is None:
+            seen = set()
+
         yield (
             ExportDatabasesCommand._file_name(model),
             lambda: ExportDatabasesCommand._file_content(model),
diff --git a/superset/commands/dataset/export.py b/superset/commands/dataset/export.py
index de373a886d1..26448f194d5 100644
--- a/superset/commands/dataset/export.py
+++ b/superset/commands/dataset/export.py
@@ -89,8 +89,12 @@ class ExportDatasetsCommand(ExportModelsCommand):
 
     @staticmethod
     def _export(
-        model: SqlaTable, export_related: bool = True
+        model: SqlaTable, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided
+        if seen is None:
+            seen = set()
+
         yield (
             ExportDatasetsCommand._file_name(model),
             lambda: ExportDatasetsCommand._file_content(model),
@@ -103,32 +107,41 @@ class ExportDatasetsCommand(ExportModelsCommand):
             )
             file_path = f"databases/{db_file_name}.yaml"
 
-            payload = model.database.export_to_dict(
-                recursive=False,
-                include_parent_ref=False,
-                include_defaults=True,
-                export_uuids=True,
-            )
-            # TODO (betodealmeida): move this logic to export_to_dict once this
-            # becomes the default export endpoint
-            if payload.get("extra"):
-                try:
-                    payload["extra"] = json.loads(payload["extra"])
-                except json.JSONDecodeError:
-                    logger.info("Unable to decode `extra` field: %s", payload["extra"])
-
-            if ssh_tunnel := model.database.ssh_tunnel:
-                ssh_tunnel_payload = ssh_tunnel.export_to_dict(
+            # Only yield the database file if not already seen. This is
+            # critical to fix the issue where databases were being
+            # duplicated and potentially overwritten when charts from
+            # different databases were exported.
+            if file_path not in seen:
+                payload = model.database.export_to_dict(
                     recursive=False,
                     include_parent_ref=False,
                     include_defaults=True,
-                    export_uuids=False,
+                    export_uuids=True,
                 )
-                payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
-
-            payload["version"] = EXPORT_VERSION
+                # TODO (betodealmeida): move this logic to export_to_dict once this
+                # becomes the default export endpoint
+                if payload.get("extra"):
+                    try:
+                        payload["extra"] = json.loads(payload["extra"])
+                    except json.JSONDecodeError:
+                        logger.info(
+                            "Unable to decode `extra` field: %s", payload["extra"]
+                        )
 
-            yield (
-                file_path,
-                lambda: yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
-            )
+                if ssh_tunnel := model.database.ssh_tunnel:
+                    ssh_tunnel_payload = ssh_tunnel.export_to_dict(
+                        recursive=False,
+                        include_parent_ref=False,
+                        include_defaults=True,
+                        export_uuids=False,
+                    )
+                    payload["ssh_tunnel"] = mask_password_info(ssh_tunnel_payload)
+
+                payload["version"] = EXPORT_VERSION
+
+                yield (
+                    file_path,
+                    lambda: yaml.safe_dump(
+                        payload, sort_keys=False, allow_unicode=True
+                    ),
+                )
diff --git a/superset/commands/export/models.py b/superset/commands/export/models.py
index f46368d5321..ead283fddb0 100644
--- a/superset/commands/export/models.py
+++ b/superset/commands/export/models.py
@@ -47,27 +47,45 @@ class ExportModelsCommand(BaseCommand):
 
     @staticmethod
     def _file_content(model: Model) -> str:
-        raise NotImplementedError("Subclasses MUST implement _export")
+        raise NotImplementedError("Subclasses MUST implement _file_content")
 
     @staticmethod
     def _export(
-        model: Model, export_related: bool = True
+        model: Model, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
         raise NotImplementedError("Subclasses MUST implement _export")
 
-    def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
+    def run(
+        self, seen: set[str] | None = None
+    ) -> Iterator[tuple[str, Callable[[], str]]]:
         self.validate()
 
-        metadata = {
-            "version": EXPORT_VERSION,
-            "type": self.dao.model_cls.__name__,  # type: ignore
-            "timestamp": datetime.now(tz=timezone.utc).isoformat(),
-        }
-        yield METADATA_FILE_NAME, lambda: yaml.safe_dump(metadata, sort_keys=False)
+        # Use provided seen set or create new one
+        if seen is None:
+            seen = set()
+            should_add_metadata = True
+        else:
+            # If seen set is provided, we're being called from another command
+            should_add_metadata = False
+
+        # Only add metadata if this is the root command
+        if should_add_metadata:
+            metadata = {
+                "version": EXPORT_VERSION,
+                "type": self.dao.model_cls.__name__,  # type: ignore
+                "timestamp": datetime.now(tz=timezone.utc).isoformat(),
+            }
+            if METADATA_FILE_NAME not in seen:
+                yield (
+                    METADATA_FILE_NAME,
+                    lambda: yaml.safe_dump(metadata, sort_keys=False),
+                )
+                seen.add(METADATA_FILE_NAME)
 
-        seen = {METADATA_FILE_NAME}
         for model in self._models:
-            for file_name, file_content in self._export(model, self.export_related):
+            for file_name, file_content in self._export(
+                model, self.export_related, seen
+            ):
                 if file_name not in seen:
                     yield file_name, file_content
                     seen.add(file_name)
diff --git a/superset/commands/query/export.py b/superset/commands/query/export.py
index e63cde5f28a..fc12057a99e 100644
--- a/superset/commands/query/export.py
+++ b/superset/commands/query/export.py
@@ -67,8 +67,12 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
 
     @staticmethod
     def _export(
-        model: SavedQuery, export_related: bool = True
+        model: SavedQuery, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided
+        if seen is None:
+            seen = set()
+
         yield (
             ExportSavedQueriesCommand._file_name(model),
             lambda: ExportSavedQueriesCommand._file_content(model),
@@ -79,21 +83,25 @@ class ExportSavedQueriesCommand(ExportModelsCommand):
             database_slug = secure_filename(model.database.database_name)
             file_name = f"databases/{database_slug}.yaml"
 
-            payload = model.database.export_to_dict(
-                recursive=False,
-                include_parent_ref=False,
-                include_defaults=True,
-                export_uuids=True,
-            )
-            # TODO (betodealmeida): move this logic to export_to_dict once this
-            # becomes the default export endpoint
-            if "extra" in payload:
-                try:
-                    payload["extra"] = json.loads(payload["extra"])
-                except json.JSONDecodeError:
-                    logger.info("Unable to decode `extra` field: %s", payload["extra"])
+            # Only yield if not already seen (similar to dataset export)
+            if file_name not in seen:
+                payload = model.database.export_to_dict(
+                    recursive=False,
+                    include_parent_ref=False,
+                    include_defaults=True,
+                    export_uuids=True,
+                )
+                # TODO (betodealmeida): move this logic to export_to_dict once this
+                # becomes the default export endpoint
+                if "extra" in payload:
+                    try:
+                        payload["extra"] = json.loads(payload["extra"])
+                    except (TypeError, json.JSONDecodeError):
+                        logger.info(
+                            "Unable to decode `extra` field: %s", payload["extra"]
+                        )
 
-            payload["version"] = EXPORT_VERSION
+                payload["version"] = EXPORT_VERSION
 
-            file_content = yaml.safe_dump(payload, sort_keys=False)
-            yield file_name, lambda: file_content
+                file_content = yaml.safe_dump(payload, sort_keys=False)
+                yield file_name, lambda: file_content
diff --git a/superset/commands/tag/export.py b/superset/commands/tag/export.py
index 99c17c34816..1d2ee6c43c4 100644
--- a/superset/commands/tag/export.py
+++ b/superset/commands/tag/export.py
@@ -46,7 +46,9 @@ class ExportTagsCommand(ExportModelsCommand):
         self.dashboard_ids = dashboard_ids
         self.chart_ids = chart_ids
 
-    def run(self) -> Iterator[tuple[str, Callable[[], str]]]:
+    def run(
+        self, seen: set[str] | None = None
+    ) -> Iterator[tuple[str, Callable[[], str]]]:
         if not feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
             return
 
diff --git a/superset/commands/theme/export.py b/superset/commands/theme/export.py
index cfb705e7e7e..d46811fe846 100644
--- a/superset/commands/theme/export.py
+++ b/superset/commands/theme/export.py
@@ -67,8 +67,12 @@ class ExportThemesCommand(ExportModelsCommand):
 
     @staticmethod
     def _export(
-        model: Theme, export_related: bool = True
+        model: Theme, export_related: bool = True, seen: set[str] | None = None
     ) -> Iterator[tuple[str, Callable[[], str]]]:
+        # Initialize seen set if not provided (for consistency)
+        if seen is None:
+            seen = set()
+
         yield (
             ExportThemesCommand._file_name(model),
             lambda: ExportThemesCommand._file_content(model),
diff --git a/tests/integration_tests/dashboards/commands_tests.py b/tests/integration_tests/dashboards/commands_tests.py
index 13324fa1acc..2ed70e1e4c9 100644
--- a/tests/integration_tests/dashboards/commands_tests.py
+++ b/tests/integration_tests/dashboards/commands_tests.py
@@ -533,6 +533,110 @@ class TestExportDashboardsCommand(SupersetTestCase):
                 {"dashboard_title": "World Bank's Data"},
             )
 
+    @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices")
+    @patch("superset.security.manager.g")
+    @patch("superset.views.base.g")
+    def test_export_dashboard_cross_database_charts(self, mock_g1, mock_g2):
+        """
+        Test that dashboards with charts from multiple databases export correctly.
+        This reproduces issue #37113 where charts from different databases were missing.
+        """
+        mock_g1.user = security_manager.find_user("admin")
+        mock_g2.user = security_manager.find_user("admin")
+
+        # Create a second database for testing
+        second_db = Database(database_name="test_db_2", sqlalchemy_uri="sqlite://")
+        db.session.add(second_db)
+
+        # Create a dataset in the second database
+        second_dataset = SqlaTable(
+            table_name="second_dataset",
+            database=second_db,
+            database_id=second_db.id,
+            columns=[],
+        )
+        db.session.add(second_dataset)
+        # Flush so `second_dataset.id` is populated before it's read below;
+        # otherwise the chart would be constructed with `datasource_id=None`
+        # and never actually link back to this dataset.
+        db.session.flush()
+
+        # Create a chart using the second database's dataset
+        chart_from_second_db = Slice(
+            slice_name="Chart from Second Database",
+            datasource_type="table",
+            datasource_id=second_dataset.id,
+            datasource_name=second_dataset.table_name,
+            viz_type="bar",
+            params=json.dumps({"viz_type": "bar"}),
+        )
+        db.session.add(chart_from_second_db)
+
+        # Get the example dashboard and add the new chart
+        example_dashboard = (
+            db.session.query(Dashboard).filter_by(slug="world_health").one()
+        )
+
+        # Store original charts count
+        original_charts_count = len(example_dashboard.slices)
+
+        # Add the new chart from different database to the dashboard
+        example_dashboard.slices.append(chart_from_second_db)
+        db.session.commit()
+
+        try:
+            # Export the dashboard
+            command = ExportDashboardsCommand([example_dashboard.id])
+            contents = dict(command.run())
+
+            # Verify all databases are exported
+            db_files = [key for key in contents.keys() if key.startswith("databases/")]
+            assert len(db_files) >= 2, (
+                f"Expected at least 2 database files, got {db_files}"
+            )
+
+            # Verify the second database is included
+            assert "databases/test_db_2.yaml" in contents.keys(), (
+                f"Second database not found in export. Keys: {list(contents.keys())}"
+            )
+
+            # Verify all charts are exported (original + new one)
+            chart_files = [key for key in contents.keys() if key.startswith("charts/")]
+            assert len(chart_files) == original_charts_count + 1, (
+                f"Expected {original_charts_count + 1} charts, got {len(chart_files)}"
+            )
+
+            # Verify the new chart from second database is included
+            chart_from_second_db_file = None
+            for key in chart_files:
+                if f"Chart_from_Second_Database_{chart_from_second_db.id}" in key:
+                    chart_from_second_db_file = key
+                    break
+
+            assert chart_from_second_db_file is not None, (
+                f"Chart from second database not found in export. "
+                f"Chart files: {chart_files}"
+            )
+
+            # Verify the dataset from second database is included
+            dataset_files = [
+                key for key in contents.keys() if key.startswith("datasets/")
+            ]
+            second_dataset_file = (
+                f"datasets/test_db_2/second_dataset_{second_dataset.id}.yaml"
+            )
+            assert second_dataset_file in contents.keys(), (
+                f"Second dataset not found. Dataset files: {dataset_files}"
+            )
+        finally:
+            # Clean up, even if an assertion above failed, so a failing run
+            # doesn't leave extra Database/Slice/SqlaTable rows for later tests.
+            example_dashboard.slices.remove(chart_from_second_db)
+            db.session.delete(chart_from_second_db)
+            db.session.delete(second_dataset)
+            db.session.delete(second_db)
+            db.session.commit()
+
 
 class TestImportDashboardsCommand(SupersetTestCase):
     def test_import_v0_dashboard_cli_export(self):