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/10/21 19:42:23 UTC

(superset) branch master updated: fix(chart): keep dataset_uuid in exports when the dataset is deleted (#44347)

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 77ebc0e55f3 fix(chart): keep dataset_uuid in exports when the dataset is deleted (#44347)
77ebc0e55f3 is described below

commit 77ebc0e55f378deee6361decb805fb2e6011e586
Author: Lukas <10...@users.noreply.github.com>
AuthorDate: Wed Oct 21 21:42:17 2026 +0200

    fix(chart): keep dataset_uuid in exports when the dataset is deleted (#44347)
    
    Signed-off-by: L4XB <lu...@e-mail.de>
---
 superset/commands/chart/export.py                  |  34 +-
 superset/commands/dashboard/export.py              |  51 +-
 superset/commands/export/assets.py                 |  75 ++-
 superset/commands/export/models.py                 |  18 +-
 tests/unit_tests/commands/chart/export_test.py     | 576 +++++++++++++++++++++
 tests/unit_tests/commands/dashboard/export_test.py |   8 +-
 .../semantic_layers/import_export_test.py          |   6 +-
 7 files changed, 743 insertions(+), 25 deletions(-)

diff --git a/superset/commands/chart/export.py b/superset/commands/chart/export.py
index fff00132fff..45bec769870 100644
--- a/superset/commands/chart/export.py
+++ b/superset/commands/chart/export.py
@@ -31,6 +31,8 @@ from superset.commands.export.models import (
     get_extra_export_fields,
 )
 from superset.commands.tag.export import ExportTagsCommand
+from superset.connectors.sqla.models import SqlaTable
+from superset.daos.dataset import DatasetDAO
 from superset.models.annotations import AnnotationLayer
 from superset.models.slice import Slice
 from superset.semantic_layers.import_export import export_view_reference
@@ -44,6 +46,7 @@ from superset.utils.file import get_filename
 from superset.utils import json
 from superset.utils.core import (
     ANNOTATION_SOURCE_TYPES_WITH_CHART_REFERENCE,
+    DatasourceType,
     get_annotation_layer_lists,
 )
 from superset.extensions import db, feature_flag_manager, security_manager
@@ -55,6 +58,26 @@ logger = logging.getLogger(__name__)
 REMOVE_KEYS = ["datasource_type", "datasource_name", "url_params"]
 
 
+def find_chart_dataset(model: Slice) -> SqlaTable | None:
+    """Resolve the dataset a chart is built on, soft-deleted ones included.
+
+    ``Slice.table`` is loaded through the soft-delete visibility filter, so a
+    chart whose dataset sits in the trash resolves to ``None`` even though
+    ``datasource_id`` still points at a row. Exporting such a chart off that
+    ``None`` drops both ``dataset_uuid`` and the ``datasets/`` file, and the
+    resulting bundle is rejected by Superset's own importer with
+    ``{"dataset_uuid": ["Missing data for required field."]}``.
+
+    The lookup keeps the DAO base filter, so a dataset the user may not read
+    stays unreachable here just as it does through the relationship.
+    """
+    if model.table:
+        return model.table
+    if model.datasource_type != DatasourceType.TABLE or model.datasource_id is None:
+        return None
+    return DatasetDAO.find_by_id(model.datasource_id, skip_visibility_filter=True)
+
+
 class ExportChartsCommand(ExportModelsCommand):
     dao = ChartDAO
     not_found = ChartNotFoundError
@@ -88,8 +111,8 @@ class ExportChartsCommand(ExportModelsCommand):
         if model.datasource_type == "semantic_view":
             if model.semantic_view:
                 payload["datasource_ref"] = export_view_reference(model.semantic_view)
-        elif model.table:
-            payload["dataset_uuid"] = str(model.table.uuid)
+        elif dataset := find_chart_dataset(model):
+            payload["dataset_uuid"] = str(dataset.uuid)
 
         # Fetch tags from the database if TAGGING_SYSTEM is enabled
         if feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"):
@@ -259,9 +282,12 @@ class ExportChartsCommand(ExportModelsCommand):
             lambda: ExportChartsCommand._file_content(model),
         )
 
-        if model.table and export_related:
+        if export_related and (dataset := find_chart_dataset(model)):
             # Pass the shared seen set to the dataset export command
-            yield from ExportDatasetsCommand([model.table.id]).run(seen=seen)
+            yield from ExportDatasetsCommand(
+                [dataset.id],
+                include_deleted=True,
+            ).run(seen=seen)
 
         if export_related:
             yield from ExportChartsCommand._export_annotation_layers(
diff --git a/superset/commands/dashboard/export.py b/superset/commands/dashboard/export.py
index 70a956d609a..0a3fbf2b2d7 100644
--- a/superset/commands/dashboard/export.py
+++ b/superset/commands/dashboard/export.py
@@ -65,6 +65,29 @@ def _coerce_dataset_id(raw_dataset_id: Any) -> Optional[int]:
     return None
 
 
+def dashboard_dataset_ids(metadata: dict[str, Any]) -> set[int]:
+    """Dataset ids a dashboard's native filters and display controls target.
+
+    A semantic-view target's ``datasetId`` names a view, not a table, so it is
+    left out here, as ``export_dashboard_references`` leaves it out for tables.
+    """
+    targets = [
+        target
+        for native_filter in metadata.get("native_filter_configuration", [])
+        for target in native_filter.get("targets", [])
+    ] + [
+        target
+        for customization in metadata.get("chart_customization_config") or []
+        for target in customization.get("targets") or []
+    ]
+    return {
+        dataset_id
+        for target in targets
+        if target.get("datasourceType") in (None, "table")
+        and (dataset_id := _coerce_dataset_id(target.get("datasetId"))) is not None
+    }
+
+
 def get_default_position(title: str) -> dict[str, Any]:
     return {
         "DASHBOARD_VERSION_KEY": "v2",
@@ -334,20 +357,14 @@ class ExportDashboardsCommand(ExportModelsCommand):
         metadata = payload.get("metadata") or {}
         export_dashboard_references(metadata)
 
-        referenced_dataset_ids = {
-            dataset_id
-            for native_filter in metadata.get("native_filter_configuration", [])
-            for target in native_filter.get("targets", [])
-            if (dataset_id := _coerce_dataset_id(target.get("datasetId"))) is not None
-        } | {
-            dataset_id
-            for customization in metadata.get("chart_customization_config") or []
-            for target in customization.get("targets") or []
-            if (dataset_id := _coerce_dataset_id(target.get("datasetId"))) is not None
-        }
+        referenced_dataset_ids = dashboard_dataset_ids(metadata)
+        # A dataset in the trash is still the one the filters point at, and the
+        # bundle carries it (see _export), so it is looked up there as well.
         datasets_by_id = {
             dataset.id: dataset
-            for dataset in DatasetDAO.find_by_ids(list(referenced_dataset_ids))
+            for dataset in DatasetDAO.find_by_ids(
+                list(referenced_dataset_ids), skip_visibility_filter=True
+            )
         }
 
         # Extract all native filter datasets and replace native
@@ -492,10 +509,16 @@ class ExportDashboardsCommand(ExportModelsCommand):
                     if dataset_id is not None:
                         referenced_dataset_ids.add(dataset_id)
 
+            # Datasets in the trash are exported too, as for a chart's dataset,
+            # so the filters' datasetUuid references resolve on import.
             found_dataset_ids = [
                 dataset.id
-                for dataset in DatasetDAO.find_by_ids(list(referenced_dataset_ids))
+                for dataset in DatasetDAO.find_by_ids(
+                    list(referenced_dataset_ids), skip_visibility_filter=True
+                )
             ]
             if found_dataset_ids:
                 # Pass the shared seen set to the dataset export command
-                yield from ExportDatasetsCommand(found_dataset_ids).run(seen=seen)
+                yield from ExportDatasetsCommand(
+                    found_dataset_ids, include_deleted=True
+                ).run(seen=seen)
diff --git a/superset/commands/export/assets.py b/superset/commands/export/assets.py
index d0dfaac031f..5e2ee7ad975 100644
--- a/superset/commands/export/assets.py
+++ b/superset/commands/export/assets.py
@@ -25,12 +25,20 @@ from superset import security_manager
 from superset.commands.annotation_layer.export import ExportAnnotationLayersCommand
 from superset.commands.base import BaseCommand
 from superset.commands.chart.export import ExportChartsCommand
-from superset.commands.dashboard.export import ExportDashboardsCommand
+from superset.commands.dashboard.export import (
+    dashboard_dataset_ids,
+    ExportDashboardsCommand,
+)
 from superset.commands.database.export import ExportDatabasesCommand
 from superset.commands.dataset.export import ExportDatasetsCommand
 from superset.commands.export.models import ExportModelsCommand
 from superset.commands.query.export import ExportSavedQueriesCommand
 from superset.commands.tag.export import ExportTagsCommand
+from superset.daos.chart import ChartDAO
+from superset.daos.dashboard import DashboardDAO
+from superset.daos.dataset import DatasetDAO
+from superset.utils import json
+from superset.utils.core import DatasourceType
 from superset.utils.dict_import_export import EXPORT_VERSION
 
 METADATA_FILE_NAME = "metadata.yaml"
@@ -66,6 +74,7 @@ class ExportAssetsCommand(BaseCommand):
 
         dashboard_ids: list[int | str] = []
         chart_ids: list[int | str] = []
+        dataset_ids: list[int | str] = []
         for command in commands:
             ids = [model.id for model in command.dao.find_all()]
             for file_name, file_content in command(ids, export_related=False).run():
@@ -77,11 +86,75 @@ class ExportAssetsCommand(BaseCommand):
                 dashboard_ids = ids
             elif command == ExportChartsCommand:
                 chart_ids = ids
+            elif command == ExportDatasetsCommand:
+                dataset_ids = ids
+
+        yield from self._export_referenced_trashed_datasets(
+            chart_ids, dashboard_ids, dataset_ids, seen
+        )
 
         yield from ExportTagsCommand(
             dashboard_ids=dashboard_ids,
             chart_ids=chart_ids,
         ).run()
 
+    @classmethod
+    def _export_referenced_trashed_datasets(
+        cls,
+        chart_ids: list[int | str],
+        dashboard_ids: list[int | str],
+        dataset_ids: list[int | str],
+        seen: set[str],
+    ) -> Iterator[tuple[str, Callable[[], str]]]:
+        if trashed_ids := cls._referenced_trashed_dataset_ids(
+            chart_ids, dashboard_ids, dataset_ids
+        ):
+            for file_name, file_content in ExportDatasetsCommand(
+                trashed_ids, export_related=False, include_deleted=True
+            ).run():
+                if file_name not in seen:
+                    yield file_name, file_content
+                    seen.add(file_name)
+
+    @staticmethod
+    def _referenced_trashed_dataset_ids(
+        chart_ids: list[int | str],
+        dashboard_ids: list[int | str],
+        dataset_ids: list[int | str],
+    ) -> list[int]:
+        """Datasets in the trash that the exported charts and native filters name.
+
+        Datasets are exported from the visibility-filtered ``find_all()``, which
+        leaves the trash out. A chart or a native filter whose dataset is in the
+        trash still writes its ``dataset_uuid`` / ``datasetUuid``, and the import
+        resolves those from the bundle, so those datasets are exported as well.
+
+        Every id goes through one lookup with the DAO base filter. A chart's
+        ``Slice.table`` relationship is loaded without it, and a dataset the user
+        cannot read would make ``ExportDatasetsCommand.validate()`` fail the whole
+        export.
+        """
+        candidate_ids = {
+            chart.datasource_id
+            for chart in ChartDAO.find_by_ids(chart_ids)
+            if chart.datasource_type == DatasourceType.TABLE
+            and chart.datasource_id is not None
+        }
+        for dashboard in DashboardDAO.find_by_ids(dashboard_ids):
+            try:
+                metadata = json.loads(dashboard.json_metadata or "{}")
+            except (TypeError, json.JSONDecodeError):
+                continue
+            # A stored null or list parses, but names no targets
+            if isinstance(metadata, dict):
+                candidate_ids |= dashboard_dataset_ids(metadata)
+        referenced = {
+            dataset.id
+            for dataset in DatasetDAO.find_by_ids(
+                list(candidate_ids), skip_visibility_filter=True
+            )
+        }
+        return sorted(referenced - set(dataset_ids))
+
     def validate(self) -> None:
         pass
diff --git a/superset/commands/export/models.py b/superset/commands/export/models.py
index a3094c2fe69..f6f470d092c 100644
--- a/superset/commands/export/models.py
+++ b/superset/commands/export/models.py
@@ -47,9 +47,19 @@ class ExportModelsCommand(BaseCommand):
     dao: type[BaseDAO[Model]] = BaseDAO
     not_found: type[CommandException] = CommandException
 
-    def __init__(self, model_ids: list[int], export_related: bool = True):
+    def __init__(
+        self,
+        model_ids: list[int],
+        export_related: bool = True,
+        include_deleted: bool = False,
+    ):
         self.model_ids = model_ids
         self.export_related = export_related
+        # Soft-deleted rows are hidden from ``find_by_ids`` by the visibility
+        # filter. Nested exports set this so a parent asset that sits in the
+        # trash still reaches the bundle, keeping the bundle's internal
+        # references resolvable; the base (permission) filter still applies.
+        self.include_deleted = include_deleted
 
         # this will be set when calling validate()
         self._models: list[Model] = []
@@ -104,6 +114,10 @@ class ExportModelsCommand(BaseCommand):
                     seen.add(file_name)
 
     def validate(self) -> None:
-        self._models = self.dao.find_by_ids(self.model_ids)
+        self._models = (
+            self.dao.find_by_ids(self.model_ids, skip_visibility_filter=True)
+            if self.include_deleted
+            else self.dao.find_by_ids(self.model_ids)
+        )
         if len(self._models) != len(self.model_ids):
             raise self.not_found()
diff --git a/tests/unit_tests/commands/chart/export_test.py b/tests/unit_tests/commands/chart/export_test.py
new file mode 100644
index 00000000000..15fe4ffe181
--- /dev/null
+++ b/tests/unit_tests/commands/chart/export_test.py
@@ -0,0 +1,576 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for ExportChartsCommand."""
+
+from __future__ import annotations
+
+from collections.abc import Generator
+
+import pytest
+import yaml
+from flask_appbuilder.security.sqla.models import User
+from pytest_mock import MockerFixture
+from sqlalchemy.orm.session import Session
+
+from superset import db, security_manager
+from superset.commands.chart.export import ExportChartsCommand
+from superset.commands.chart.importers.v1 import ImportChartsCommand
+from superset.commands.dashboard.export import ExportDashboardsCommand
+from superset.commands.dashboard.importers.v1 import ImportDashboardsCommand
+from superset.commands.dataset.exceptions import DatasetNotFoundError
+from superset.commands.dataset.export import ExportDatasetsCommand
+from superset.commands.export.assets import ExportAssetsCommand
+from superset.commands.importers.v1.assets import ImportAssetsCommand
+from superset.connectors.sqla.models import Database, SqlaTable
+from superset.daos.dataset import DatasetDAO
+from superset.models.dashboard import Dashboard
+from superset.models.slice import Slice
+from superset.subjects.models import Subject
+from superset.subjects.types import SubjectType
+from superset.utils import json
+from superset.utils.core import override_user
+
+
+@pytest.fixture
+def chart_on_dataset(session: Session) -> Generator[Slice, None, None]:
+    """A chart backed by a dataset, both alive."""
+    engine = session.get_bind()
+    SqlaTable.metadata.create_all(engine)  # pylint: disable=no-member
+
+    dataset = SqlaTable(
+        table_name="my_table",
+        metrics=[],
+        main_dttm_col=None,
+        database=Database(database_name="my_database", sqlalchemy_uri="sqlite://"),
+    )
+    session.add(dataset)
+    session.flush()
+
+    chart = Slice(
+        slice_name="my_chart",
+        viz_type="table",
+        datasource_id=dataset.id,
+        datasource_type="table",
+        datasource_name="my_table",
+        params="{}",
+    )
+    session.add(chart)
+    session.flush()
+
+    yield chart
+    session.rollback()
+
+
+@pytest.fixture
+def permissive_security(mocker: MockerFixture) -> None:
+    """Grant every read/write check the export and import paths consult."""
+    mocker.patch.object(
+        security_manager, "can_access_all_datasources", return_value=True
+    )
+    mocker.patch.object(security_manager, "can_access", return_value=True)
+    mocker.patch.object(security_manager, "is_editor", return_value=True)
+    mocker.patch.object(security_manager, "is_admin", return_value=True)
+
+
+@pytest.fixture
+def chart_editor_without_dataset_access(
+    chart_on_dataset: Slice,
+) -> Generator[User, None, None]:
+    """Log in a user who edits the chart but holds no grant on its dataset.
+
+    The user has no role, so no ``all_datasource_access``, ``database_access``,
+    ``datasource_access``, ``schema_access`` or ``catalog_access``, and is not
+    an editor of the dataset, so ``DatasourceFilter`` hides the dataset from
+    them in the trash as well. Editing the chart is what lets ``ChartFilter``
+    hand them the chart.
+    """
+    user = User(
+        first_name="chart",
+        last_name="editor",
+        username="chart_editor",
+        email="chart_editor@example.com",
+        active=True,
+    )
+    db.session.add(user)
+    db.session.flush()
+    chart_on_dataset.editors = [
+        Subject(label="chart_editor", type=SubjectType.USER, user_id=user.id)
+    ]
+    db.session.flush()
+
+    with override_user(user):
+        yield user
+
+
+def _export(chart: Slice, export_related: bool = True) -> dict[str, str]:
+    return {
+        file_name: file_content()
+        for file_name, file_content in ExportChartsCommand(
+            [chart.id], export_related=export_related
+        ).run()
+    }
+
+
+def _next_request() -> None:
+    """Drop everything loaded in this session.
+
+    ``Slice.table`` is ``lazy="subquery"``, so a relationship loaded before the
+    delete stays warm in the identity map and would mask the visibility filter.
+    Export runs in its own request in a deployment; expiring models that.
+    """
+    db.session.expire_all()
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_export_chart_bundle_is_importable_when_dataset_is_soft_deleted(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """A chart whose dataset was deleted still exports a complete bundle.
+
+    Deleting a dataset soft-deletes it, which hides it from ``Slice.table``
+    while the chart itself survives. The export must still name the dataset,
+    otherwise ``charts/*.yaml`` carries no ``dataset_uuid`` and the bundle is
+    rejected on import with ``Missing data for required field``.
+    """
+    chart = chart_on_dataset
+    dataset = chart.table
+    dataset_uuid = str(dataset.uuid)
+
+    DatasetDAO.delete([dataset])
+    db.session.flush()
+    _next_request()
+    assert chart.table is None, "dataset should be hidden from the relationship"
+
+    contents = _export(chart)
+
+    chart_files = [name for name in contents if name.startswith("charts/")]
+    dataset_files = [name for name in contents if name.startswith("datasets/")]
+    assert len(chart_files) == 1
+    assert len(dataset_files) == 1, "the dataset must travel with the chart"
+
+    payload = yaml.safe_load(contents[chart_files[0]])
+    assert payload["dataset_uuid"] == dataset_uuid
+    assert yaml.safe_load(contents[dataset_files[0]])["uuid"] == dataset_uuid
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_export_import_round_trip_restores_chart_and_dataset(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """Export → delete → import restores both assets (issue #44309)."""
+    chart = chart_on_dataset
+    dataset = chart.table
+    dataset_uuid = str(dataset.uuid)
+
+    DatasetDAO.delete([dataset])
+    db.session.flush()
+    _next_request()
+
+    contents = _export(chart)
+
+    # Change what the import has to write back, so the assertions below fail if
+    # the chart is skipped rather than imported.
+    chart.slice_name = "renamed after the export"
+    chart.datasource_id = None
+    db.session.flush()
+
+    ImportChartsCommand(contents, overwrite=True).run()
+    db.session.flush()
+
+    restored = db.session.query(SqlaTable).filter_by(uuid=dataset.uuid).one()
+    assert restored.deleted_at is None, "the dataset must come back out of the trash"
+    assert str(restored.uuid) == dataset_uuid
+
+    reimported = db.session.query(Slice).filter_by(uuid=chart.uuid).one()
+    assert reimported.slice_name == "my_chart"
+    assert reimported.datasource_id == restored.id
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_dashboard_filter_keeps_a_soft_deleted_dataset_through_import(
+    app_context: None,
+    chart_on_dataset: Slice,
+    mocker: MockerFixture,
+) -> None:
+    """A dashboard filter on a dataset in the trash keeps its target (#44309).
+
+    The filter's dataset lookup skipped the trash, so the export dropped the
+    target's ``datasetId`` without writing a ``datasetUuid``, and the imported
+    filter pointed at no dataset. The filter targets a dataset that no chart on
+    the dashboard uses, so only the dashboard's own lookup can bundle it.
+    """
+    chart = chart_on_dataset
+    filter_dataset = SqlaTable(
+        table_name="filter_table",
+        metrics=[],
+        main_dttm_col=None,
+        database=chart.table.database,
+    )
+    db.session.add(filter_dataset)
+    db.session.flush()
+    target = {"datasetId": filter_dataset.id, "column": {"name": "ds"}}
+    dashboard = Dashboard(
+        dashboard_title="my_dashboard",
+        slices=[chart],
+        json_metadata=json.dumps(
+            {"native_filter_configuration": [{"id": "F-1", "targets": [target]}]}
+        ),
+    )
+    db.session.add(dashboard)
+    db.session.flush()
+
+    DatasetDAO.delete([filter_dataset])
+    db.session.flush()
+    _next_request()
+
+    contents = {
+        file_name: file_content()
+        for file_name, file_content in ExportDashboardsCommand([dashboard.id]).run()
+    }
+
+    dashboard_file = next(name for name in contents if name.startswith("dashboards/"))
+    exported = yaml.safe_load(contents[dashboard_file])["metadata"]
+    assert exported["native_filter_configuration"][0]["targets"] == [
+        {"datasetUuid": str(filter_dataset.uuid), "column": {"name": "ds"}}
+    ]
+    exported_dataset_uuids = {
+        yaml.safe_load(contents[name])["uuid"]
+        for name in contents
+        if name.startswith("datasets/")
+    }
+    assert str(filter_dataset.uuid) in exported_dataset_uuids
+
+    # Drop the filter, so it only comes back if the import writes it.
+    dashboard.json_metadata = json.dumps({})
+    db.session.flush()
+    mocker.patch(
+        "superset.commands.dashboard.importers.v1.get_default_viewers_for_current_user",
+        return_value=[],
+    )
+
+    ImportDashboardsCommand(contents, overwrite=True).run()
+    db.session.flush()
+
+    restored = db.session.query(SqlaTable).filter_by(uuid=filter_dataset.uuid).one()
+    assert restored.deleted_at is None, "the dataset must come back out of the trash"
+    reimported = db.session.query(Dashboard).filter_by(uuid=dashboard.uuid).one()
+    imported = json.loads(reimported.json_metadata)["native_filter_configuration"]
+    assert imported[0]["targets"] == [
+        {"datasetId": restored.id, "column": {"name": "ds"}}
+    ]
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_dataset_export_leaves_out_a_soft_deleted_dataset_unless_asked(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """Only nested exports reach into the trash.
+
+    The dataset export endpoint and every other caller keep the default
+    ``include_deleted=False``, so a readable dataset in the trash is not found
+    there, while ``include_deleted=True`` exports it.
+    """
+    dataset = chart_on_dataset.table
+    dataset_id, dataset_uuid = dataset.id, str(dataset.uuid)
+
+    DatasetDAO.delete([dataset])
+    db.session.flush()
+    _next_request()
+
+    with pytest.raises(DatasetNotFoundError):
+        list(ExportDatasetsCommand([dataset_id]).run())
+
+    contents = {
+        file_name: file_content()
+        for file_name, file_content in ExportDatasetsCommand(
+            [dataset_id], include_deleted=True
+        ).run()
+    }
+    dataset_files = [name for name in contents if name.startswith("datasets/")]
+    assert len(dataset_files) == 1
+    assert yaml.safe_load(contents[dataset_files[0]])["uuid"] == dataset_uuid
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_semantic_view_chart_does_not_bundle_the_table_with_its_id(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """A semantic-view chart's ``datasource_id`` is not a dataset id.
+
+    ``find_chart_dataset`` only looks a dataset up by ``datasource_id`` for a
+    table chart. Here a readable table has the same numeric id as the view, and
+    exporting the chart with its related assets must not bundle that table.
+    """
+    table = chart_on_dataset.table
+    chart = Slice(
+        slice_name="semantic_chart",
+        viz_type="table",
+        datasource_type="semantic_view",
+        datasource_id=table.id,
+        params="{}",
+    )
+    db.session.add(chart)
+    db.session.flush()
+    _next_request()
+
+    contents = _export(chart, export_related=True)
+
+    assert [name for name in contents if name.startswith("charts/")]
+    assert not [
+        name for name in contents if name.startswith(("datasets/", "databases/"))
+    ]
+    chart_file = next(name for name in contents if name.startswith("charts/"))
+    assert "dataset_uuid" not in yaml.safe_load(contents[chart_file])
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_assets_export_carries_the_soft_deleted_datasets_it_references(
+    app_context: None,
+    chart_on_dataset: Slice,
+    mocker: MockerFixture,
+) -> None:
+    """The full export also bundles a trashed dataset that its files name.
+
+    Datasets come from the visibility-filtered ``find_all()``, but the chart
+    still writes ``dataset_uuid`` for a dataset in the trash and a native
+    filter its ``datasetUuid``. Without the dataset file, ``ImportAssetsCommand``
+    raised a ``KeyError`` looking the chart's dataset up in the bundle.
+    """
+    chart = chart_on_dataset
+    dataset = chart.table
+    filter_dataset = SqlaTable(
+        table_name="filter_table",
+        metrics=[],
+        main_dttm_col=None,
+        database=dataset.database,
+    )
+    db.session.add(filter_dataset)
+    db.session.flush()
+    target = {"datasetId": filter_dataset.id, "column": {"name": "ds"}}
+    dashboard = Dashboard(
+        dashboard_title="my_dashboard",
+        slices=[chart],
+        json_metadata=json.dumps(
+            {"native_filter_configuration": [{"id": "F-1", "targets": [target]}]}
+        ),
+    )
+    db.session.add(dashboard)
+    db.session.flush()
+    trashed_uuids = {str(dataset.uuid), str(filter_dataset.uuid)}
+
+    DatasetDAO.delete([dataset, filter_dataset])
+    db.session.flush()
+    _next_request()
+
+    contents = {
+        file_name: file_content()
+        for file_name, file_content in ExportAssetsCommand().run()
+    }
+
+    exported_dataset_uuids = {
+        yaml.safe_load(contents[name])["uuid"]
+        for name in contents
+        if name.startswith("datasets/")
+    }
+    assert trashed_uuids <= exported_dataset_uuids
+
+    mocker.patch(
+        "superset.commands.importers.v1.assets.get_default_viewers_for_current_user",
+        return_value=[],
+    )
+    ImportAssetsCommand(contents).run()
+    db.session.flush()
+
+    restored = db.session.query(SqlaTable).filter(
+        SqlaTable.uuid.in_([dataset.uuid, filter_dataset.uuid])
+    )
+    assert {str(table.uuid) for table in restored if table.deleted_at is None} == (
+        trashed_uuids
+    )
+
+
+def test_assets_export_leaves_out_a_live_dataset_the_user_cannot_read(
+    app_context: None,
+    chart_editor_without_dataset_access: User,
+    chart_on_dataset: Slice,
+) -> None:
+    """A chart's dataset counts only if the DAO base filter lets the user read it.
+
+    ``Slice.table`` is loaded without that filter, so the chart named a live
+    dataset the user holds no grant on, and ``ExportDatasetsCommand.validate()``
+    then failed the whole export with ``DatasetNotFoundError``.
+    """
+    _next_request()
+
+    assert (
+        ExportAssetsCommand._referenced_trashed_dataset_ids(
+            [chart_on_dataset.id], [], []
+        )
+        == []
+    )
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_assets_export_reads_dashboards_whose_metadata_is_not_an_object(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """A stored ``null`` or ``[]`` parses, and must not stop the other dashboards."""
+    dataset = chart_on_dataset.table
+    target = {"datasetId": dataset.id, "column": {"name": "ds"}}
+    dashboards = [
+        Dashboard(dashboard_title="null", json_metadata="null"),
+        Dashboard(dashboard_title="list", json_metadata="[]"),
+        Dashboard(
+            dashboard_title="filters",
+            json_metadata=json.dumps(
+                {"native_filter_configuration": [{"id": "F-1", "targets": [target]}]}
+            ),
+        ),
+    ]
+    db.session.add_all(dashboards)
+    db.session.flush()
+    DatasetDAO.delete([dataset])
+    db.session.flush()
+    _next_request()
+
+    assert ExportAssetsCommand._referenced_trashed_dataset_ids(
+        [], [dashboard.id for dashboard in dashboards], []
+    ) == [dataset.id]
+
+
+@pytest.mark.usefixtures("permissive_security")
+@pytest.mark.parametrize(
+    ("datasource_type", "expected"),
+    [("semantic_view", False), ("table", True), (None, True)],
+)
+def test_assets_export_does_not_read_a_semantic_view_target_as_a_table(
+    app_context: None,
+    chart_on_dataset: Slice,
+    datasource_type: str | None,
+    expected: bool,
+) -> None:
+    """A semantic-view target's ``datasetId`` names a view; a trashed table that
+    shares the number is not bundled for it."""
+    dataset = chart_on_dataset.table
+    target: dict[str, object] = {"datasetId": dataset.id, "column": {"name": "ds"}}
+    if datasource_type is not None:
+        target["datasourceType"] = datasource_type
+    dashboard = Dashboard(
+        dashboard_title="filters",
+        json_metadata=json.dumps(
+            {"native_filter_configuration": [{"id": "F-1", "targets": [target]}]}
+        ),
+    )
+    db.session.add(dashboard)
+    db.session.flush()
+    DatasetDAO.delete([dataset])
+    db.session.flush()
+    _next_request()
+
+    referenced = ExportAssetsCommand._referenced_trashed_dataset_ids(
+        [], [dashboard.id], []
+    )
+
+    assert referenced == ([dataset.id] if expected else [])
+
+
+@pytest.mark.usefixtures("permissive_security")
+def test_export_chart_omits_dataset_uuid_when_dataset_is_gone(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """A dangling ``datasource_id`` still exports, without a dataset link.
+
+    Hard-deleted datasets (``SOFT_DELETE`` off, or after the retention purge)
+    leave no row to resolve, so the export has nothing to name. The chart file
+    is still produced rather than the command raising.
+    """
+    chart = chart_on_dataset
+    dataset = chart.table
+
+    DatasetDAO.hard_delete([dataset])
+    db.session.flush()
+    _next_request()
+
+    contents = _export(chart)
+
+    chart_files = [name for name in contents if name.startswith("charts/")]
+    assert len(chart_files) == 1
+    assert not [name for name in contents if name.startswith("datasets/")]
+    assert "dataset_uuid" not in yaml.safe_load(contents[chart_files[0]])
+
+
+@pytest.mark.usefixtures("chart_editor_without_dataset_access")
+@pytest.mark.parametrize("export_related", [True, False])
+def test_export_chart_does_not_leak_deleted_dataset_without_access(
+    app_context: None,
+    chart_on_dataset: Slice,
+    export_related: bool,
+) -> None:
+    """Resolving a soft-deleted dataset must not widen dataset access.
+
+    ``find_chart_dataset`` lifts only the soft-delete filter. A caller who can
+    export the chart but cannot read its dataset gets the chart alone: no
+    ``datasets/`` or ``databases/`` file and no ``dataset_uuid``, the same
+    bundle the export produced for them before the dataset could be resolved
+    out of the trash.
+    """
+    chart = chart_on_dataset
+    dataset_id = chart.table.id
+
+    DatasetDAO.delete([chart.table])
+    db.session.flush()
+    _next_request()
+
+    # The row is still there, so only the missing access keeps it out.
+    assert DatasetDAO.find_by_id(
+        dataset_id, skip_base_filter=True, skip_visibility_filter=True
+    )
+
+    contents = _export(chart, export_related)
+
+    chart_files = [name for name in contents if name.startswith("charts/")]
+    assert len(chart_files) == 1
+    assert not [
+        name for name in contents if name.startswith(("datasets/", "databases/"))
+    ]
+    assert "dataset_uuid" not in yaml.safe_load(contents[chart_files[0]])
+
+
+@pytest.mark.usefixtures("chart_editor_without_dataset_access")
+def test_nested_dataset_export_keeps_access_filter_for_deleted_dataset(
+    app_context: None,
+    chart_on_dataset: Slice,
+) -> None:
+    """``include_deleted`` lifts the soft-delete filter and nothing else.
+
+    The chart export passes it to the nested dataset export, so a caller who
+    cannot read the dataset must still be refused there.
+    """
+    dataset_id = chart_on_dataset.table.id
+
+    DatasetDAO.delete([chart_on_dataset.table])
+    db.session.flush()
+    _next_request()
+
+    with pytest.raises(DatasetNotFoundError):
+        list(ExportDatasetsCommand([dataset_id], include_deleted=True).run())
diff --git a/tests/unit_tests/commands/dashboard/export_test.py b/tests/unit_tests/commands/dashboard/export_test.py
index 0a80b980041..9c0d925e2e2 100644
--- a/tests/unit_tests/commands/dashboard/export_test.py
+++ b/tests/unit_tests/commands/dashboard/export_test.py
@@ -167,8 +167,10 @@ def test_file_content_batches_dataset_lookup_across_targets():
 
     mock_find_by_id.assert_not_called()
     mock_find_by_ids.assert_called_once()
-    (called_ids,), _ = mock_find_by_ids.call_args
+    (called_ids,), kwargs = mock_find_by_ids.call_args
     assert set(called_ids) == {1, 2}
+    # A target in the trash still resolves to its dataset.
+    assert kwargs == {"skip_visibility_filter": True}
 
     result = yaml.safe_load(content)
     native_filters = result["metadata"]["native_filter_configuration"]
@@ -241,6 +243,8 @@ def test_export_batches_dataset_export_across_targets():
 
     mock_find_by_id.assert_not_called()
     mock_find_by_ids.assert_called_once()
+    # A dataset in the trash is still bundled for the filters that use it.
+    assert mock_find_by_ids.call_args.kwargs == {"skip_visibility_filter": True}
     mock_datasets_cls.assert_called_once()
     mock_datasets_cmd.run.assert_called_once()
     (called_ids,), _ = mock_datasets_cls.call_args
@@ -426,7 +430,7 @@ def test_export_yields_dataset_files_for_display_controls():
         mock_charts_cls.return_value.run.return_value = iter([])
         results = list(ExportDashboardsCommand._export(mock_dashboard))
 
-    mock_datasets_cls.assert_called_once_with([dataset_id])
+    mock_datasets_cls.assert_called_once_with([dataset_id], include_deleted=True)
     mock_datasets_cmd.run.assert_called_once()
     filenames = [name for name, _ in results]
     assert "datasets/my_dataset.yaml" in filenames
diff --git a/tests/unit_tests/semantic_layers/import_export_test.py b/tests/unit_tests/semantic_layers/import_export_test.py
index 0f2f25aa33b..08aeac8f961 100644
--- a/tests/unit_tests/semantic_layers/import_export_test.py
+++ b/tests/unit_tests/semantic_layers/import_export_test.py
@@ -896,7 +896,9 @@ def test_orphaned_dashboard_target_does_not_abort_mixed_export(
         dashboard_export.DashboardDAO, "find_by_ids", lambda ids: [source, plain]
     )
     monkeypatch.setattr(
-        dashboard_export.DatasetDAO, "find_by_ids", lambda ids: [table] if ids else []
+        dashboard_export.DatasetDAO,
+        "find_by_ids",
+        lambda ids, **kwargs: [table] if ids else [],
     )
     monkeypatch.setattr(
         dashboard_export.ExportChartsCommand, "run", lambda *args, **kwargs: iter(())
@@ -916,7 +918,7 @@ def test_orphaned_dashboard_target_does_not_abort_mixed_export(
     targets: list[dict[str, Any]] = exported["metadata"][control][0]["targets"]
     assert targets[0] == {"datasourceType": "semantic_view"}
     assert targets[1]["datasetUuid"] == VIEW_UUID
-    dataset_export.assert_called_once_with([81])
+    dataset_export.assert_called_once_with([81], include_deleted=True)
     with pytest.raises(refs.SemanticReferenceError, match="requires datasourceRef"):
         refs.resolve_bundle_references({"dashboards/orphan.yaml": exported})