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})