You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/10/14 02:41:33 UTC
(superset) branch master updated: fix(semantic-layer): block deletion of sources with dependent assets (#45071)
This is an automated email from the ASF dual-hosted git repository.
aminghadersohi 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 81d4137b98b fix(semantic-layer): block deletion of sources with dependent assets (#45071)
81d4137b98b is described below
commit 81d4137b98b14317256a8cfb6b22886a8692c498
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Tue Oct 13 22:41:25 2026 -0400
fix(semantic-layer): block deletion of sources with dependent assets (#45071)
Co-authored-by: Mike Bridge <mi...@ext.preset.io>
Co-authored-by: Claude Opus 5.5 <no...@anthropic.com>
---
.../importing-exporting-datasources.mdx | 22 +
.../DatabaseList.semanticLayerDelete.test.tsx | 51 +-
.../DatasetList/DatasetList.behavior.test.tsx | 101 ++++
superset-frontend/src/pages/DatasetList/index.tsx | 38 +-
superset/commands/semantic_layer/delete.py | 233 ++++++++
superset/commands/semantic_layer/exceptions.py | 28 +-
superset/semantic_layers/api.py | 109 ++++
superset/translations/messages.pot | 10 +
.../commands/semantic_layer/delete_test.py | 634 ++++++++++++++++++++-
tests/unit_tests/semantic_layers/api_test.py | 84 +++
10 files changed, 1290 insertions(+), 20 deletions(-)
diff --git a/docs/admin_docs/configuration/importing-exporting-datasources.mdx b/docs/admin_docs/configuration/importing-exporting-datasources.mdx
index 21c224680a2..f1b99438af1 100644
--- a/docs/admin_docs/configuration/importing-exporting-datasources.mdx
+++ b/docs/admin_docs/configuration/importing-exporting-datasources.mdx
@@ -144,6 +144,28 @@ create provider connections or views, or contact providers. Provision the matchi
view and access grants separately before importing. Full cross-workspace semantic
provisioning is outside this workflow.
+## Deleting semantic sources
+
+Deleting a semantic view, multiple views, or a semantic layer returns HTTP 409
+while any affected view is used by a live chart, a live dashboard containing
+that chart or targeting the view in a native filter or display control, or an
+active alert or report schedule. The response includes `total` dependent assets,
+up to 20 `dependents` the caller may list (each with `type`, `id`, and `name`), and
+`inaccessible_count` for dependent assets the caller cannot list. The `type`
+values are `chart`, `dashboard`, `alert`, and `report`. Remove or reassign the
+dependent assets before retrying the delete. The error message includes the full
+dependent count, including assets not named in the bounded list. Charts and
+dashboards take precedence over alerts and reports in that list. For a mixed
+bulk deletion, datasets and semantic views are separate requests: datasets can
+succeed while the semantic-view batch is refused. The error toast names the
+refused views and shows the reason.
+
+Archived charts and dashboards do not block deletion. Restoring one after its
+semantic view was deleted leaves it without that datasource. The dependency
+check is best-effort: a chart, native filter, or display control created or
+re-pointed between the check and deletion can still become orphaned, so
+coordinate concurrent edits when deleting a source used by active workflows.
+
## UUIDs in API Responses
The REST API POST endpoints for **datasets**, **charts**, and **dashboards** include the auto-generated `uuid` field in the response body:
diff --git a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
index 1b0074e31a4..cf4afdffa15 100644
--- a/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
+++ b/superset-frontend/src/pages/DatabaseList/DatabaseList.semanticLayerDelete.test.tsx
@@ -29,6 +29,12 @@ import {
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import DatabaseList from 'src/pages/DatabaseList';
+import { ComponentType } from 'react';
+
+jest.mock('src/components/MessageToasts/withToasts', () => ({
+ __esModule: true,
+ default: <P extends object>(Component: ComponentType<P>) => Component,
+}));
/**
* Deleting a semantic layer cascade-deletes its semantic views (SC-108418).
@@ -96,9 +102,11 @@ const setupMocks = ({
rows = [semanticLayerRow],
permissions = ['can_read', 'can_write', 'can_export'],
uploadResponse = { result: [], count: 0 },
+ deleteError,
}: {
dependents: { id: number; table_name: string }[];
dependentsError?: boolean;
+ deleteError?: string;
rows?: (typeof semanticLayerRow)[];
permissions?: string[];
uploadResponse?:
@@ -127,10 +135,13 @@ const setupMocks = ({
{ name: DATASOURCE_ROUTE },
);
}
- fetchMock.delete(DELETE_ROUTE, {});
+ fetchMock.delete(
+ DELETE_ROUTE,
+ deleteError ? { status: 409, body: { message: deleteError } } : {},
+ );
};
-const renderDatabaseList = (user = mockUser) => {
+const renderDatabaseList = (user = mockUser, addDangerToast = jest.fn()) => {
const store = configureStore({
reducer: {
user: (state = user) => state,
@@ -150,11 +161,18 @@ const renderDatabaseList = (user = mockUser) => {
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }),
});
- return render(<DatabaseList user={user} />, {
- store,
- useQueryParams: true,
- useRouter: true,
- });
+ return render(
+ <DatabaseList
+ user={user}
+ addDangerToast={addDangerToast}
+ addSuccessToast={jest.fn()}
+ />,
+ {
+ store,
+ useQueryParams: true,
+ useRouter: true,
+ },
+ );
};
const openDeleteModal = async () => {
@@ -423,6 +441,25 @@ test("a stale lookup resolving late cannot replace a newer row's modal", async (
expect(screen.queryByText('stale_view')).not.toBeInTheDocument();
});
+test('layer delete displays the counted dependency conflict', async () => {
+ const reason =
+ 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ setupMocks({ dependents: [], deleteError: reason });
+ const addDangerToast = jest.fn();
+ renderDatabaseList(mockUser, addDangerToast);
+ const dialog = await openDeleteModal();
+ await userEvent.type(
+ within(dialog).getByTestId('delete-modal-input'),
+ 'DELETE',
+ );
+ await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
+ await waitFor(() =>
+ expect(addDangerToast).toHaveBeenCalledWith(
+ `There was an issue deleting Demo Semantic Layer: ${reason}`,
+ ),
+ );
+});
+
test('confirming the modal deletes the semantic layer', async () => {
setupMocks({ dependents: [dependentView(1, 'marketing')] });
renderDatabaseList();
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
index e257e7462f0..097e8c02102 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.behavior.test.tsx
@@ -73,6 +73,107 @@ afterEach(async () => {
jest.restoreAllMocks();
});
+test('single semantic view delete displays the counted dependency conflict', async () => {
+ const view = {
+ ...mockDatasets[0],
+ id: 99,
+ table_name: 'orders_semantic',
+ kind: 'semantic_view',
+ };
+ const reason =
+ 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ const addDangerToast = jest.fn();
+ mockDatasetListEndpoints({ result: [view], count: 1 });
+ fetchMock.delete('glob:*/api/v1/semantic_view/99', {
+ status: 409,
+ body: { message: reason },
+ });
+ renderDatasetList(mockAdminUser, {
+ addDangerToast,
+ addSuccessToast: jest.fn(),
+ });
+ await screen.findByText(view.table_name);
+ await userEvent.click(screen.getByTestId('dataset-row-delete'));
+ const modal = await screen.findByRole('dialog');
+ await userEvent.type(
+ within(modal).getByTestId('delete-modal-input'),
+ 'DELETE',
+ );
+ await userEvent.click(within(modal).getByRole('button', { name: 'Delete' }));
+ await waitFor(() =>
+ expect(addDangerToast).toHaveBeenCalledWith(
+ `There was an issue deleting orders_semantic: ${reason}`,
+ ),
+ );
+});
+
+test('mixed bulk delete names refused semantic views and their conflict reason', async () => {
+ const dataset = mockDatasets[0];
+ const semanticView = {
+ ...mockDatasets[1],
+ id: 99,
+ table_name: 'orders_semantic',
+ kind: 'semantic_view',
+ };
+ const addDangerToast = jest.fn();
+ const reason =
+ 'Semantic source is used by 25 dependent assets and cannot be deleted.';
+ mockDatasetListEndpoints({ result: [dataset, semanticView], count: 2 });
+ fetchMock.get(API_ENDPOINTS.DATASET_BULK_RELATED_OBJECTS, {
+ charts: { count: 0, result: [] },
+ dashboards: { count: 0, result: [] },
+ });
+ fetchMock.delete(API_ENDPOINTS.DATASET_BULK_DELETE, { message: 'OK' });
+ fetchMock.delete('glob:*/api/v1/semantic_view/?q=*', {
+ status: 409,
+ body: {
+ message: reason,
+ total: 25,
+ dependents: [],
+ inaccessible_count: 25,
+ },
+ });
+ renderDatasetList(mockAdminUser, {
+ addDangerToast,
+ addSuccessToast: jest.fn(),
+ });
+ await screen.findByText(semanticView.table_name);
+ await userEvent.click(screen.getByRole('button', { name: /bulk select/i }));
+ const table = screen.getByTestId('listview-table');
+ for (const name of [dataset.table_name, semanticView.table_name]) {
+ const cell = await within(table).findByText(name);
+ await userEvent.click(within(cell.closest('tr')!).getByRole('checkbox'));
+ }
+ await waitFor(() =>
+ expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
+ '2 Selected',
+ ),
+ );
+ await userEvent.click(
+ within(screen.getByTestId('bulk-select-controls')).getByRole('button', {
+ name: 'Delete',
+ }),
+ );
+ const modal = await screen.findByRole('dialog');
+ await userEvent.type(
+ within(modal).getByTestId('delete-modal-input'),
+ 'DELETE',
+ );
+ await userEvent.click(within(modal).getByRole('button', { name: 'Delete' }));
+ await waitFor(() =>
+ expect(addDangerToast).toHaveBeenCalledWith(
+ `Could not delete semantic views orders_semantic: ${reason}`,
+ ),
+ );
+ expect(addDangerToast).toHaveBeenCalledTimes(1);
+ expect(
+ fetchMock.callHistory.calls(API_ENDPOINTS.DATASET_BULK_DELETE),
+ ).toHaveLength(1);
+ expect(
+ fetchMock.callHistory.calls('glob:*/api/v1/semantic_view/?q=*'),
+ ).toHaveLength(1);
+});
+
test('typing in search updates the input value correctly', async () => {
renderDatasetList(mockAdminUser);
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx b/superset-frontend/src/pages/DatasetList/index.tsx
index ff39e83cc3f..4c246e32f75 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -1455,7 +1455,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
}
- Promise.allSettled(promises).then(results => {
+ Promise.allSettled(promises).then(async results => {
const failures = results.filter(r => r.status === 'rejected');
// Always refresh so the list reflects whatever actually got deleted.
refreshData();
@@ -1478,17 +1478,33 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
}
} else {
- addDangerToast(
- softDelete
- ? t(
- 'There was an issue archiving the selected %s',
- datasetsLabelLower(),
- )
- : t(
- 'There was an issue deleting the selected %s',
- datasetsLabelLower(),
+ const semanticResult = semanticViews.length
+ ? results[results.length - 1]
+ : undefined;
+ if (semanticResult?.status === 'rejected') {
+ await createErrorHandler(errMsg =>
+ addDangerToast(
+ t(
+ 'Could not delete semantic views %s: %s',
+ semanticViews.map(view => view.table_name).join(', '),
+ errMsg,
),
- );
+ ),
+ )(semanticResult.reason);
+ }
+ if (datasets.length && results[0].status === 'rejected') {
+ addDangerToast(
+ softDelete
+ ? t(
+ 'There was an issue archiving the selected %s',
+ datasetsLabelLower(),
+ )
+ : t(
+ 'There was an issue deleting the selected %s',
+ datasetsLabelLower(),
+ ),
+ );
+ }
}
});
};
diff --git a/superset/commands/semantic_layer/delete.py b/superset/commands/semantic_layer/delete.py
index 289e8eca376..1416819c672 100644
--- a/superset/commands/semantic_layer/delete.py
+++ b/superset/commands/semantic_layer/delete.py
@@ -18,11 +18,18 @@ from __future__ import annotations
import logging
from functools import partial
+from typing import cast
+import sqlalchemy as sa
+from flask_appbuilder.models.sqla.interface import SQLAInterface
+from flask_babel import gettext as _
from sqlalchemy.exc import SQLAlchemyError
+from superset import db, security_manager
+from superset.charts.filters import ChartFilter
from superset.commands.base import BaseCommand
from superset.commands.semantic_layer.exceptions import (
+ SemanticDeleteDependentsError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
@@ -32,11 +39,226 @@ from superset.commands.semantic_layer.exceptions import (
)
from superset.commands.utils import current_user_can_modify_object
from superset.daos.semantic_layer import SemanticLayerDAO, SemanticViewDAO
+from superset.dashboards.filters import DashboardAccessFilter
from superset.exceptions import SupersetSecurityException
+from superset.models.dashboard import Dashboard, dashboard_slices
+from superset.models.slice import Slice
+from superset.reports.filters import ReportScheduleFilter
+from superset.reports.models import ReportSchedule
from superset.semantic_layers.models import SemanticLayer, SemanticView
+from superset.utils import json
from superset.utils.decorators import on_error, transaction
logger = logging.getLogger(__name__)
+_DEPENDENT_LIMIT: int = 20
+
+
+def _semantic_target_matches(target: object, view_ids: set[int]) -> bool:
+ """A missing type denotes a table, even when its integer ID collides."""
+ if not isinstance(target, dict) or target.get("datasourceType") != "semantic_view":
+ return False
+ raw_id: object = target.get("datasetId")
+ if isinstance(raw_id, bool) or not (
+ isinstance(raw_id, int)
+ or (isinstance(raw_id, str) and raw_id.isascii() and raw_id.isdecimal())
+ ):
+ return False
+ try:
+ return int(raw_id) in view_ids
+ except ValueError:
+ return False
+
+
+def _dashboard_targets_view(
+ metadata: str, view_ids: set[int], dashboard_id: int
+) -> bool:
+ """Match typed semantic targets in native filters or display controls."""
+ try:
+ decoded: object = json.loads(metadata)
+ except (TypeError, ValueError, RecursionError):
+ logger.debug("Skipping malformed targets on dashboard %s", dashboard_id)
+ return False
+ if not isinstance(decoded, dict):
+ logger.debug("Skipping non-object targets on dashboard %s", dashboard_id)
+ return False
+ for key in ("native_filter_configuration", "chart_customization_config"):
+ controls: object = decoded.get(key)
+ if not isinstance(controls, list):
+ continue
+ for control in controls:
+ if not isinstance(control, dict):
+ continue
+ targets: object = control.get("targets")
+ if isinstance(targets, list) and any(
+ _semantic_target_matches(target, view_ids) for target in targets
+ ):
+ return True
+ return False
+
+
+def _dashboard_target_ids(view_ids: sa.Select) -> set[int]:
+ """Find live dashboards with semantic targets after a coarse SQL prefilter."""
+ selected_ids: set[int] = set(db.session.execute(view_ids).scalars())
+ if not selected_ids:
+ return set()
+ candidates: sa.Select = sa.select(Dashboard.id, Dashboard.json_metadata).where(
+ Dashboard.deleted_at.is_(None),
+ Dashboard.json_metadata.like("%semantic_view%"),
+ )
+ matched_ids: set[int] = set()
+ for dashboard_id, metadata in db.session.execute(
+ candidates.execution_options(yield_per=1000)
+ ):
+ if _dashboard_targets_view(metadata, selected_ids, dashboard_id):
+ matched_ids.add(dashboard_id)
+ return matched_ids
+
+
+def _dependent_assets(
+ view_ids: sa.Select,
+) -> tuple[int, list[dict[str, str | int]], int]:
+ """Count dependents, naming only assets visible through their list APIs."""
+ chart_ids: sa.Select = sa.select(Slice.id).where(
+ Slice.datasource_type == "semantic_view",
+ Slice.datasource_id.in_(view_ids),
+ Slice.deleted_at.is_(None),
+ )
+ chart_dashboard_ids: sa.Select = (
+ sa.select(dashboard_slices.c.dashboard_id)
+ .join(Slice, dashboard_slices.c.slice_id == Slice.id)
+ .join(Dashboard, dashboard_slices.c.dashboard_id == Dashboard.id)
+ .where(
+ Slice.id.in_(chart_ids),
+ Dashboard.deleted_at.is_(None),
+ )
+ .distinct()
+ )
+ dashboard_target_ids: set[int] = _dashboard_target_ids(view_ids)
+ dashboard_ids: sa.Select = sa.select(Dashboard.id).where(
+ Dashboard.deleted_at.is_(None),
+ sa.or_(
+ Dashboard.id.in_(chart_dashboard_ids),
+ Dashboard.id.in_(dashboard_target_ids),
+ ),
+ )
+ dependents: sa.Subquery = sa.union_all(
+ sa.select(
+ sa.literal("chart").label("type"),
+ Slice.id.label("id"),
+ sa.func.coalesce(Slice.slice_name, str(_("Untitled"))).label("name"),
+ ).where(Slice.id.in_(chart_ids)),
+ sa.select(
+ sa.literal("dashboard").label("type"),
+ Dashboard.id.label("id"),
+ sa.func.coalesce(Dashboard.dashboard_title, str(_("Untitled"))).label(
+ "name"
+ ),
+ ).where(Dashboard.id.in_(dashboard_ids)),
+ sa.select(
+ sa.func.lower(ReportSchedule.type).label("type"),
+ ReportSchedule.id.label("id"),
+ ReportSchedule.name.label("name"),
+ ).where(
+ ReportSchedule.active.is_(True),
+ sa.or_(
+ ReportSchedule.chart_id.in_(chart_ids),
+ ReportSchedule.dashboard_id.in_(dashboard_ids),
+ ),
+ ),
+ ).subquery()
+ visible_chart_ids: sa.Select = (
+ cast(
+ sa.Select,
+ ChartFilter("id", SQLAInterface(Slice, db.session))
+ .apply(db.session.query(Slice.id), None)
+ .statement,
+ )
+ if security_manager.can_access("can_read", "Chart")
+ else sa.select(Slice.id).where(sa.false())
+ )
+ visible_dashboard_ids: sa.Select = (
+ cast(
+ sa.Select,
+ DashboardAccessFilter("id", SQLAInterface(Dashboard, db.session))
+ .apply(db.session.query(Dashboard.id), None)
+ .statement,
+ )
+ if security_manager.can_access("can_read", "Dashboard")
+ else sa.select(Dashboard.id).where(sa.false())
+ )
+ visible_schedule_ids: sa.Select = (
+ cast(
+ sa.Select,
+ ReportScheduleFilter("id", SQLAInterface(ReportSchedule, db.session))
+ .apply(db.session.query(ReportSchedule.id), None)
+ .statement,
+ )
+ if security_manager.can_access("can_read", "ReportSchedule")
+ else sa.select(ReportSchedule.id).where(sa.false())
+ )
+ visibility: sa.ColumnElement[bool] = sa.or_(
+ sa.and_(
+ dependents.c.type == "chart",
+ dependents.c.id.in_(visible_chart_ids),
+ ),
+ sa.and_(
+ dependents.c.type == "dashboard",
+ dependents.c.id.in_(visible_dashboard_ids),
+ ),
+ sa.and_(
+ dependents.c.type.notin_(("chart", "dashboard")),
+ dependents.c.id.in_(visible_schedule_ids),
+ ),
+ )
+ # Both counts come from one statement so READ COMMITTED cannot mix snapshots.
+ counts: tuple[int, int | None] = cast(
+ tuple[int, int | None],
+ db.session.execute(
+ sa.select(
+ sa.func.count(),
+ sa.func.sum(sa.case((visibility, 1), else_=0)),
+ ).select_from(dependents)
+ ).one(),
+ )
+ total: int = int(counts[0])
+ if not total:
+ return 0, [], 0
+ visible_total: int = int(counts[1] or 0)
+ visible: sa.Subquery = sa.select(dependents).where(visibility).subquery()
+ rows: list[tuple[str, int, str]] = cast(
+ list[tuple[str, int, str]],
+ db.session.execute(
+ sa.select(visible.c.type, visible.c.id, visible.c.name)
+ .order_by(
+ sa.case(
+ (visible.c.type == "chart", 0),
+ (visible.c.type == "dashboard", 1),
+ else_=2,
+ ),
+ visible.c.type,
+ visible.c.id,
+ )
+ .limit(_DEPENDENT_LIMIT)
+ ).all(),
+ )
+ return (
+ total,
+ [
+ {"type": asset_type, "id": asset_id, "name": name}
+ for asset_type, asset_id, name in rows
+ ],
+ total - visible_total,
+ )
+
+
+def _raise_for_dependents(view_ids: sa.Select) -> None:
+ """Best-effort guard; a concurrent chart write can still orphan a view."""
+ total: int
+ dependents: list[dict[str, str | int]]
+ inaccessible_count: int
+ total, dependents, inaccessible_count = _dependent_assets(view_ids)
+ if total:
+ raise SemanticDeleteDependentsError(total, dependents, inaccessible_count)
class DeleteSemanticLayerCommand(BaseCommand):
@@ -67,6 +289,11 @@ class DeleteSemanticLayerCommand(BaseCommand):
if not current_user_can_modify_object(self._model):
raise SemanticLayerForbiddenError()
+ _raise_for_dependents(
+ sa.select(SemanticView.id).where(
+ SemanticView.semantic_layer_uuid == self._model.uuid
+ )
+ )
class DeleteSemanticViewCommand(BaseCommand):
@@ -97,6 +324,9 @@ class DeleteSemanticViewCommand(BaseCommand):
if not current_user_can_modify_object(self._model):
raise SemanticViewForbiddenError()
+ _raise_for_dependents(
+ sa.select(SemanticView.id).where(SemanticView.id == self._model.id)
+ )
class BulkDeleteSemanticViewCommand(BaseCommand):
@@ -127,3 +357,6 @@ class BulkDeleteSemanticViewCommand(BaseCommand):
if not current_user_can_modify_object(model):
raise SemanticViewForbiddenError()
+ _raise_for_dependents(
+ sa.select(SemanticView.id).where(SemanticView.id.in_(self._model_ids))
+ )
diff --git a/superset/commands/semantic_layer/exceptions.py b/superset/commands/semantic_layer/exceptions.py
index ec1a4ac838d..95f329ddc02 100644
--- a/superset/commands/semantic_layer/exceptions.py
+++ b/superset/commands/semantic_layer/exceptions.py
@@ -14,7 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
-from flask_babel import lazy_gettext as _
+from flask_babel import lazy_gettext as _, ngettext
from superset.commands.exceptions import (
CommandException,
@@ -74,3 +74,29 @@ class SemanticViewCreateFailedError(CreateFailedError):
class SemanticViewDeleteFailedError(DeleteFailedError):
message = _("Semantic view could not be deleted.")
+
+
+class SemanticDeleteDependentsError(CommandException):
+ """A semantic view or layer still has live dependent assets."""
+
+ status: int = 409
+
+ def __init__(
+ self,
+ total: int,
+ dependents: list[dict[str, str | int]],
+ inaccessible_count: int = 0,
+ ) -> None:
+ self.total: int = total
+ self.dependents: list[dict[str, str | int]] = dependents
+ self.inaccessible_count: int = inaccessible_count
+ super().__init__(
+ ngettext(
+ "Semantic source is used by %(count)s dependent asset "
+ "and cannot be deleted.",
+ "Semantic source is used by %(count)s dependent assets "
+ "and cannot be deleted.",
+ total,
+ count=total,
+ )
+ )
diff --git a/superset/semantic_layers/api.py b/superset/semantic_layers/api.py
index c23244b635e..3f6ad72342e 100644
--- a/superset/semantic_layers/api.py
+++ b/superset/semantic_layers/api.py
@@ -40,6 +40,7 @@ from superset.commands.semantic_layer.delete import (
DeleteSemanticViewCommand,
)
from superset.commands.semantic_layer.exceptions import (
+ SemanticDeleteDependentsError,
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
@@ -510,6 +511,34 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
+ 409:
+ description: Semantic view has dependent assets
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [message, total, dependents, inaccessible_count]
+ properties:
+ message:
+ type: string
+ total:
+ type: integer
+ dependents:
+ type: array
+ maxItems: 20
+ items:
+ type: object
+ required: [type, id, name]
+ properties:
+ type:
+ type: string
+ enum: [chart, dashboard, alert, report]
+ id:
+ type: integer
+ name:
+ type: string
+ inaccessible_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -523,6 +552,14 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):
return self.response_404()
except SemanticViewForbiddenError:
return self.response_403()
+ except SemanticDeleteDependentsError as ex:
+ return self.response(
+ 409,
+ message=str(ex),
+ total=ex.total,
+ dependents=ex.dependents,
+ inaccessible_count=ex.inaccessible_count,
+ )
except SemanticViewDeleteFailedError as ex:
logger.error(
"Error deleting semantic view: %s",
@@ -567,6 +604,34 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
+ 409:
+ description: A semantic view has dependent assets
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [message, total, dependents, inaccessible_count]
+ properties:
+ message:
+ type: string
+ total:
+ type: integer
+ dependents:
+ type: array
+ maxItems: 20
+ items:
+ type: object
+ required: [type, id, name]
+ properties:
+ type:
+ type: string
+ enum: [chart, dashboard, alert, report]
+ id:
+ type: integer
+ name:
+ type: string
+ inaccessible_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -588,6 +653,14 @@ class SemanticViewRestApi(_RuntimeGuardedApiMixin, BaseSupersetModelRestApi):
return self.response_404()
except SemanticViewForbiddenError:
return self.response_403()
+ except SemanticDeleteDependentsError as ex:
+ return self.response(
+ 409,
+ message=str(ex),
+ total=ex.total,
+ dependents=ex.dependents,
+ inaccessible_count=ex.inaccessible_count,
+ )
except SemanticViewDeleteFailedError as ex:
logger.error(
"Error bulk deleting semantic views: %s",
@@ -1050,6 +1123,34 @@ class SemanticLayerRestApi(_RuntimeGuardedApiMixin, BaseSupersetApi):
$ref: '#/components/responses/403'
404:
$ref: '#/components/responses/404'
+ 409:
+ description: Semantic layer has dependent assets
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [message, total, dependents, inaccessible_count]
+ properties:
+ message:
+ type: string
+ total:
+ type: integer
+ dependents:
+ type: array
+ maxItems: 20
+ items:
+ type: object
+ required: [type, id, name]
+ properties:
+ type:
+ type: string
+ enum: [chart, dashboard, alert, report]
+ id:
+ type: integer
+ name:
+ type: string
+ inaccessible_count:
+ type: integer
422:
$ref: '#/components/responses/422'
"""
@@ -1060,6 +1161,14 @@ class SemanticLayerRestApi(_RuntimeGuardedApiMixin, BaseSupersetApi):
return self.response_404()
except SemanticLayerForbiddenError as ex:
return self.response(403, message=str(ex))
+ except SemanticDeleteDependentsError as ex:
+ return self.response(
+ 409,
+ message=str(ex),
+ total=ex.total,
+ dependents=ex.dependents,
+ inaccessible_count=ex.inaccessible_count,
+ )
except SemanticLayerDeleteFailedError as ex:
logger.error(
"Error deleting semantic layer: %s",
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index 799cb63d93b..3d2c4aaa9ab 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -4103,6 +4103,10 @@ msgstr ""
msgid "Could not connect to database: \"%(database)s\""
msgstr ""
+#, python-format
+msgid "Could not delete semantic views %s: %s"
+msgstr ""
+
msgid "Could not fetch all saved charts"
msgstr ""
@@ -12267,6 +12271,12 @@ msgstr ""
msgid "Semantic layer updated"
msgstr ""
+#, python-format
+msgid "Semantic source is used by %(count)s dependent asset and cannot be deleted."
+msgid_plural "Semantic source is used by %(count)s dependent assets and cannot be deleted."
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Semantic view could not be created."
msgstr ""
diff --git a/tests/unit_tests/commands/semantic_layer/delete_test.py b/tests/unit_tests/commands/semantic_layer/delete_test.py
index 01d9f6da704..cbae5816216 100644
--- a/tests/unit_tests/commands/semantic_layer/delete_test.py
+++ b/tests/unit_tests/commands/semantic_layer/delete_test.py
@@ -18,10 +18,17 @@
from unittest.mock import MagicMock
import pytest
+import sqlalchemy as sa
from pytest_mock import MockerFixture
+from sqlalchemy.engine import Connection
+from sqlalchemy.orm import Session
-from superset.commands.semantic_layer.delete import DeleteSemanticLayerCommand
+from superset.commands.semantic_layer.delete import (
+ _semantic_target_matches,
+ DeleteSemanticLayerCommand,
+)
from superset.commands.semantic_layer.exceptions import (
+ SemanticDeleteDependentsError,
SemanticLayerForbiddenError,
SemanticLayerNotFoundError,
)
@@ -31,6 +38,11 @@ from superset.exceptions import SupersetSecurityException
def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
"""Test successful deletion of a semantic layer."""
mock_model = MagicMock()
+ mock_model.uuid = "00000000-0000-0000-0000-000000000001"
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
@@ -48,6 +60,32 @@ def test_delete_semantic_layer_success(mocker: MockerFixture) -> None:
dao.delete.assert_called_once_with([mock_model])
+def test_delete_semantic_layer_refuses_dependents(mocker: MockerFixture) -> None:
+ """A layer delete must not cascade away a view still used by a chart."""
+ model: MagicMock = MagicMock()
+ model.uuid = "00000000-0000-0000-0000-000000000001"
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticLayerDAO"
+ )
+ dao.find_by_uuid.return_value = model
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ )
+
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ DeleteSemanticLayerCommand("layer-uuid").run()
+
+ dao.delete.assert_not_called()
+ assert exc_info.value.total == 1
+ assert exc_info.value.dependents == [{"type": "chart", "id": 7, "name": "Revenue"}]
+
+
def test_delete_semantic_layer_not_found(mocker: MockerFixture) -> None:
"""Test that SemanticLayerNotFoundError is raised when model is missing."""
dao = mocker.patch(
@@ -97,6 +135,11 @@ def test_delete_semantic_layer_creator_allowed(mocker: MockerFixture) -> None:
"""A non-admin who created the layer, but holds no explicit editorship
on it, can still delete it."""
mock_model = MagicMock()
+ mock_model.uuid = "00000000-0000-0000-0000-000000000001"
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticLayerDAO",
@@ -141,6 +184,11 @@ def test_delete_semantic_layer_non_creator_non_editor_forbidden(
def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful deletion of a semantic view."""
mock_model = MagicMock()
+ mock_model.id = 42
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
@@ -161,6 +209,577 @@ def test_delete_semantic_view_success(mocker: MockerFixture) -> None:
dao.delete.assert_called_once_with([mock_model])
+def test_delete_semantic_view_refuses_dependents(mocker: MockerFixture) -> None:
+ """A view still used by a chart must not be hard-deleted."""
+ from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
+
+ model: MagicMock = MagicMock()
+ model.id = 42
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticViewDAO"
+ )
+ dao.find_by_id.return_value = model
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ )
+
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ DeleteSemanticViewCommand(42).run()
+
+ dao.delete.assert_not_called()
+ assert exc_info.value.total == 1
+ assert exc_info.value.dependents == [{"type": "chart", "id": 7, "name": "Revenue"}]
+
+
+def test_bulk_delete_semantic_views_refuses_all_on_one_dependency(
+ mocker: MockerFixture,
+) -> None:
+ """A blocked view prevents every member of the bulk hard delete."""
+ from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
+
+ models: list[MagicMock] = [MagicMock(id=42), MagicMock(id=43)]
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticViewDAO"
+ )
+ dao.find_by_ids.return_value = models
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(1, [{"type": "chart", "id": 7, "name": "Revenue"}], 0),
+ )
+
+ with pytest.raises(SemanticDeleteDependentsError):
+ BulkDeleteSemanticViewCommand([42, 43]).run()
+
+ dao.delete.assert_not_called()
+
+
+@pytest.mark.parametrize("orm_execution", [False, True])
+def test_semantic_delete_lists_live_dependents_only(
+ session: Session, mocker: MockerFixture, orm_execution: bool
+) -> None:
+ """Charts, dashboard membership and active schedules form the dependency set."""
+ from datetime import datetime
+
+ from superset.commands.semantic_layer.delete import _dependent_assets
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.reports.models import ReportSchedule
+
+ Slice.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ connection: Connection = (
+ session.connection() if orm_execution else session.get_bind().connect()
+ )
+ connection.execute(
+ Slice.__table__.insert().values( # pylint: disable=no-member
+ id=711,
+ slice_name="Live chart",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ )
+ )
+ connection.execute(
+ Slice.__table__.insert().values( # pylint: disable=no-member
+ id=712,
+ slice_name="Deleted chart",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ deleted_at=datetime(2026, 1, 1),
+ )
+ )
+ connection.execute(
+ Slice.__table__.insert().values( # pylint: disable=no-member
+ id=717,
+ slice_name=None,
+ datasource_type="semantic_view",
+ datasource_id=42,
+ )
+ )
+ connection.execute(
+ Dashboard.__table__.insert().values( # pylint: disable=no-member
+ id=713, dashboard_title="Live dashboard"
+ )
+ )
+ connection.exec_driver_sql(
+ "INSERT INTO dashboard_slices (dashboard_id, slice_id) VALUES (713, 711)"
+ )
+ connection.execute(
+ ReportSchedule.__table__.insert(), # pylint: disable=no-member
+ [
+ {
+ "id": 714,
+ "type": "Alert",
+ "name": "Live alert",
+ "crontab": "* * * * *",
+ "chart_id": 711,
+ "dashboard_id": None,
+ "active": True,
+ },
+ {
+ "id": 715,
+ "type": "Report",
+ "name": "Live report",
+ "crontab": "* * * * *",
+ "dashboard_id": 713,
+ "chart_id": None,
+ "active": True,
+ },
+ {
+ "id": 716,
+ "type": "Report",
+ "name": "Inactive report",
+ "crontab": "* * * * *",
+ "chart_id": 711,
+ "dashboard_id": None,
+ "active": False,
+ },
+ ],
+ )
+ if not orm_execution:
+ mocker.patch("superset.db.session.scalar", side_effect=connection.scalar)
+ mocker.patch("superset.db.session.execute", side_effect=connection.execute)
+ mocker.patch("superset.security_manager.can_access", return_value=True)
+ mocker.patch(
+ "superset.security_manager.can_access_all_datasources", return_value=True
+ )
+ mocker.patch("superset.security_manager.is_admin", return_value=True)
+
+ total: int
+ dependents: list[dict[str, str | int]]
+ inaccessible_count: int
+ total, dependents, inaccessible_count = _dependent_assets(sa.select(sa.literal(42)))
+
+ assert total == 5
+ assert inaccessible_count == 0
+ assert {item["name"] for item in dependents} == {
+ "Live chart",
+ "Live dashboard",
+ "Live alert",
+ "Live report",
+ "Untitled",
+ }
+ assert {item["type"] for item in dependents} == {
+ "chart",
+ "dashboard",
+ "alert",
+ "report",
+ }
+ mocker.patch("superset.commands.semantic_layer.delete._DEPENDENT_LIMIT", 2)
+ capped_total: int
+ capped_dependents: list[dict[str, str | int]]
+ capped_inaccessible_count: int
+ capped_total, capped_dependents, capped_inaccessible_count = _dependent_assets(
+ sa.select(sa.literal(42))
+ )
+ assert capped_total == 5
+ assert len(capped_dependents) == 2
+ assert [item["type"] for item in capped_dependents] == ["chart", "chart"]
+ assert capped_inaccessible_count == 0
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.ChartFilter.apply",
+ return_value=session.query(Slice.id).filter(sa.false()),
+ )
+ hidden_total: int
+ hidden_dependents: list[dict[str, str | int]]
+ hidden_count: int
+ hidden_total, hidden_dependents, hidden_count = _dependent_assets(
+ sa.select(sa.literal(42))
+ )
+ assert hidden_total == 5
+ assert hidden_count == 2
+ assert all(dependent["type"] != "chart" for dependent in hidden_dependents)
+ if not orm_execution:
+ connection.close()
+
+
+def test_semantic_delete_finds_native_filter_dashboards(
+ session: Session, mocker: MockerFixture
+) -> None:
+ """Only live, typed semantic targets block deletion; membership is deduped."""
+ import uuid
+ from datetime import datetime
+
+ from superset.commands.semantic_layer.delete import (
+ _dependent_assets,
+ DeleteSemanticViewCommand,
+ )
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.reports.models import ReportSchedule
+ from superset.semantic_layers.models import SemanticLayer, SemanticView
+ from superset.utils import json
+
+ Slice.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ connection: Connection = session.get_bind().connect()
+ layer_uuid: uuid.UUID = uuid.uuid4()
+
+ def metadata(source_type: str | None, dataset_id: int | str) -> str:
+ target: dict[str, str | int] = {"datasetId": dataset_id}
+ if source_type is not None:
+ target["datasourceType"] = source_type
+ return json.dumps({"native_filter_configuration": [{"targets": [target]}]})
+
+ try:
+ connection.execute(
+ SemanticLayer.__table__.insert().values( # pylint: disable=no-member
+ uuid=layer_uuid, name="Source", type="test"
+ )
+ )
+ connection.execute(
+ SemanticView.__table__.insert().values( # pylint: disable=no-member
+ id=42, name="View", semantic_layer_uuid=layer_uuid
+ )
+ )
+ connection.execute(
+ Dashboard.__table__.insert(), # pylint: disable=no-member
+ [
+ {
+ "id": 731,
+ "dashboard_title": "Typed integer",
+ "json_metadata": metadata("semantic_view", 42),
+ },
+ {
+ "id": 732,
+ "dashboard_title": "Typed string",
+ "json_metadata": metadata("semantic_view", "42"),
+ },
+ {
+ "id": 733,
+ "dashboard_title": "Table",
+ "json_metadata": json.dumps(
+ {
+ "note": "semantic_view",
+ "native_filter_configuration": [
+ {
+ "targets": [
+ {"datasetId": 42, "datasourceType": "table"}
+ ]
+ }
+ ],
+ }
+ ),
+ },
+ {
+ "id": 734,
+ "dashboard_title": "Legacy table",
+ "json_metadata": json.dumps(
+ {
+ "note": "semantic_view",
+ "native_filter_configuration": [
+ {"targets": [{"datasetId": 42}]}
+ ],
+ }
+ ),
+ },
+ {
+ "id": 735,
+ "dashboard_title": "Malformed",
+ "json_metadata": '{"native_filter_configuration": "semantic_view"',
+ },
+ {
+ "id": 737,
+ "dashboard_title": "Non-dict",
+ "json_metadata": '"semantic_view"',
+ },
+ {
+ "id": 739,
+ "dashboard_title": "Filter and chart",
+ "json_metadata": metadata("semantic_view", 42),
+ },
+ ],
+ )
+ connection.execute(
+ Dashboard.__table__.insert().values( # pylint: disable=no-member
+ id=736,
+ dashboard_title="Deleted",
+ json_metadata=metadata("semantic_view", 42),
+ deleted_at=datetime(2026, 1, 1),
+ )
+ )
+ connection.execute(
+ Slice.__table__.insert().values( # pylint: disable=no-member
+ id=738,
+ slice_name="Member chart",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ )
+ )
+ connection.exec_driver_sql(
+ "INSERT INTO dashboard_slices (dashboard_id, slice_id) VALUES (739, 738)"
+ )
+ connection.execute(
+ ReportSchedule.__table__.insert().values( # pylint: disable=no-member
+ id=740,
+ type="Report",
+ name="Native report",
+ crontab="* * * * *",
+ dashboard_id=731,
+ active=True,
+ )
+ )
+ mocker.patch("superset.db.session.scalar", side_effect=connection.scalar)
+ mocker.patch("superset.db.session.execute", side_effect=connection.execute)
+ mocker.patch("superset.security_manager.can_access", return_value=True)
+ mocker.patch(
+ "superset.security_manager.can_access_all_datasources", return_value=True
+ )
+ mocker.patch("superset.security_manager.is_admin", return_value=True)
+
+ total: int
+ dependents: list[dict[str, str | int]]
+ inaccessible_count: int
+ total, dependents, inaccessible_count = _dependent_assets(
+ sa.select(SemanticView.id).where(SemanticView.id == 42)
+ )
+ assert total == 5
+ assert inaccessible_count == 0
+ assert {item["name"] for item in dependents} == {
+ "Typed integer",
+ "Typed string",
+ "Filter and chart",
+ "Member chart",
+ "Native report",
+ }
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticViewDAO"
+ )
+ dao.find_by_id.return_value = MagicMock(id=42)
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+ with pytest.raises(SemanticDeleteDependentsError):
+ DeleteSemanticViewCommand(42).run()
+ dao.delete.assert_not_called()
+
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.DashboardAccessFilter.apply",
+ return_value=session.query(Dashboard.id).filter(sa.false()),
+ )
+ hidden_total: int
+ hidden_dependents: list[dict[str, str | int]]
+ hidden_count: int
+ hidden_total, hidden_dependents, hidden_count = _dependent_assets(
+ sa.select(SemanticView.id).where(SemanticView.id == 42)
+ )
+ assert hidden_total == 5
+ assert hidden_count == 3
+ assert {item["name"] for item in hidden_dependents} == {
+ "Member chart",
+ "Native report",
+ }
+ finally:
+ connection.close()
+
+
+@pytest.mark.parametrize(
+ "config_key", ["native_filter_configuration", "chart_customization_config"]
+)
+def test_semantic_layer_delete_finds_dashboard_target(
+ session: Session, mocker: MockerFixture, config_key: str
+) -> None:
+ """A filter-only or display-control-only dashboard blocks layer deletion."""
+ import uuid
+
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.semantic_layers.models import SemanticLayer, SemanticView
+ from superset.utils import json
+
+ Slice.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ connection: Connection = session.get_bind().connect()
+ layer_uuid: uuid.UUID = uuid.uuid4()
+ try:
+ connection.execute(
+ SemanticLayer.__table__.insert().values( # pylint: disable=no-member
+ uuid=layer_uuid, name="Source", type="test"
+ )
+ )
+ connection.execute(
+ SemanticView.__table__.insert().values( # pylint: disable=no-member
+ id=42, name="View", semantic_layer_uuid=layer_uuid
+ )
+ )
+ connection.execute(
+ Dashboard.__table__.insert().values( # pylint: disable=no-member
+ id=741,
+ dashboard_title="Filter-only",
+ json_metadata=json.dumps(
+ {
+ config_key: [
+ {
+ "targets": [
+ {"datasetId": 42, "datasourceType": "semantic_view"}
+ ]
+ }
+ ]
+ }
+ ),
+ )
+ )
+ mocker.patch("superset.db.session.scalar", side_effect=connection.scalar)
+ mocker.patch("superset.db.session.execute", side_effect=connection.execute)
+ mocker.patch("superset.security_manager.can_access", return_value=True)
+ mocker.patch(
+ "superset.security_manager.can_access_all_datasources", return_value=True
+ )
+ mocker.patch("superset.security_manager.is_admin", return_value=True)
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticLayerDAO"
+ )
+ model: MagicMock = MagicMock(uuid=layer_uuid)
+ dao.find_by_uuid.return_value = model
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ DeleteSemanticLayerCommand(str(layer_uuid)).run()
+ assert exc_info.value.total == 1
+ assert exc_info.value.dependents == [
+ {"type": "dashboard", "id": 741, "name": "Filter-only"}
+ ]
+ dao.delete.assert_not_called()
+ finally:
+ connection.close()
+
+
+def test_semantic_delete_skips_json_recursion_error(mocker: MockerFixture) -> None:
+ """Invalid stored metadata must not turn a protected delete into a 500."""
+ from superset.commands.semantic_layer.delete import _dashboard_targets_view
+
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.json.loads",
+ side_effect=RecursionError,
+ )
+ assert not _dashboard_targets_view('"semantic_view"', {42}, 741)
+
+
+@pytest.mark.parametrize(
+ "raw_id", [True, pytest.param("1" * 4301, id="oversized"), "x", "٤٢"]
+)
+def test_semantic_target_rejects_non_ids(raw_id: object) -> None:
+ """Reject boolean, malformed, oversized and non-ASCII string identifiers."""
+ target: dict[str, object] = {"datasourceType": "semantic_view", "datasetId": raw_id}
+ assert not _semantic_target_matches(target, {1, 42})
+
+
+def test_semantic_delete_hides_unreadable_dependent(
+ session: Session, mocker: MockerFixture
+) -> None:
+ """A 409 counts unreadable charts, dashboards and reports without naming them."""
+ import uuid
+
+ from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
+ from superset.models.dashboard import Dashboard
+ from superset.models.slice import Slice
+ from superset.reports.models import ReportSchedule
+ from superset.semantic_layers.models import SemanticLayer, SemanticView
+
+ Slice.metadata.create_all(session.get_bind()) # pylint: disable=no-member
+ connection: Connection = session.get_bind().connect()
+ try:
+ layer_uuid: uuid.UUID = uuid.uuid4()
+ connection.execute(
+ SemanticLayer.__table__.insert().values( # pylint: disable=no-member
+ uuid=layer_uuid, name="Source", type="test"
+ )
+ )
+ connection.execute(
+ SemanticView.__table__.insert().values( # pylint: disable=no-member
+ id=42, name="View", semantic_layer_uuid=layer_uuid
+ )
+ )
+ connection.execute(
+ Slice.__table__.insert().values( # pylint: disable=no-member
+ id=721,
+ slice_name="Private chart",
+ datasource_type="semantic_view",
+ datasource_id=42,
+ )
+ )
+ connection.execute(
+ Dashboard.__table__.insert().values( # pylint: disable=no-member
+ id=722, dashboard_title="Private dashboard"
+ )
+ )
+ connection.exec_driver_sql(
+ "INSERT INTO dashboard_slices (dashboard_id, slice_id) VALUES (722, 721)"
+ )
+ connection.execute(
+ ReportSchedule.__table__.insert().values( # pylint: disable=no-member
+ id=723,
+ type="Report",
+ name="Private report",
+ crontab="* * * * *",
+ chart_id=721,
+ active=True,
+ )
+ )
+ mocker.patch("superset.db.session.scalar", side_effect=connection.scalar)
+ mocker.patch("superset.db.session.execute", side_effect=connection.execute)
+ mocker.patch(
+ "superset.security_manager.can_access",
+ return_value=False,
+ )
+ dao: MagicMock = mocker.patch(
+ "superset.commands.semantic_layer.delete.SemanticViewDAO"
+ )
+ model: MagicMock = MagicMock()
+ model.id = 42
+ dao.find_by_id.return_value = model
+ mocker.patch(
+ "superset.commands.semantic_layer.delete.current_user_can_modify_object",
+ return_value=True,
+ )
+
+ exc_info: pytest.ExceptionInfo[SemanticDeleteDependentsError]
+ with pytest.raises(SemanticDeleteDependentsError) as exc_info:
+ DeleteSemanticViewCommand(42).run()
+
+ dao.delete.assert_not_called()
+ assert exc_info.value.status == 409
+ assert exc_info.value.total == 3
+ assert exc_info.value.dependents == []
+ assert exc_info.value.inaccessible_count == 3
+ finally:
+ connection.close()
+
+
+def test_semantic_delete_counts_use_one_snapshot(
+ session: Session, mocker: MockerFixture
+) -> None:
+ """A concurrent insertion cannot make the hidden count negative."""
+ from superset.commands.semantic_layer.delete import _dependent_assets
+
+ stale_scalar: MagicMock = mocker.patch(
+ "superset.db.session.scalar", side_effect=[1, 2]
+ )
+ rows: MagicMock = mocker.patch("superset.db.session.execute")
+ rows.return_value.one.return_value = (1, 0)
+ rows.return_value.all.return_value = []
+ mocker.patch("superset.security_manager.can_access", return_value=False)
+
+ total: int
+ dependents: list[dict[str, str | int]]
+ inaccessible_count: int
+ total, dependents, inaccessible_count = _dependent_assets(sa.select(sa.literal(42)))
+
+ assert (total, dependents, inaccessible_count) == (1, [], 1)
+ stale_scalar.assert_not_called()
+
+
def test_delete_semantic_view_forbidden(mocker: MockerFixture) -> None:
"""Test that SemanticViewForbiddenError is raised for non-owners."""
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
@@ -188,6 +807,11 @@ def test_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> None:
from superset.commands.semantic_layer.delete import DeleteSemanticViewCommand
mock_model = MagicMock()
+ mock_model.id = 42
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
@@ -251,6 +875,10 @@ def test_delete_semantic_view_not_found(mocker: MockerFixture) -> None:
def test_bulk_delete_semantic_view_success(mocker: MockerFixture) -> None:
"""Test successful bulk deletion of semantic views."""
mock_models = [MagicMock(), MagicMock()]
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
@@ -295,6 +923,10 @@ def test_bulk_delete_semantic_view_creator_allowed(mocker: MockerFixture) -> Non
from superset.commands.semantic_layer.delete import BulkDeleteSemanticViewCommand
mock_models = [MagicMock(), MagicMock()]
+ mocker.patch(
+ "superset.commands.semantic_layer.delete._dependent_assets",
+ return_value=(0, [], 0),
+ )
dao = mocker.patch(
"superset.commands.semantic_layer.delete.SemanticViewDAO",
diff --git a/tests/unit_tests/semantic_layers/api_test.py b/tests/unit_tests/semantic_layers/api_test.py
index 7a8794201c2..5eecac36cbc 100644
--- a/tests/unit_tests/semantic_layers/api_test.py
+++ b/tests/unit_tests/semantic_layers/api_test.py
@@ -28,6 +28,7 @@ from pytest_mock import MockerFixture
from werkzeug.test import TestResponse
from superset.commands.semantic_layer.exceptions import (
+ SemanticDeleteDependentsError,
SemanticLayerCreateFailedError,
SemanticLayerDeleteFailedError,
SemanticLayerForbiddenError,
@@ -1006,6 +1007,89 @@ def test_delete_semantic_layer_failed(
assert response.status_code == 422
+@SEMANTIC_LAYERS_APP
+@pytest.mark.parametrize(
+ ("path", "command"),
+ [
+ ("/api/v1/semantic_view/1", "DeleteSemanticViewCommand"),
+ ("/api/v1/semantic_view/?q=(1,2)", "BulkDeleteSemanticViewCommand"),
+ (
+ "/api/v1/semantic_layer/00000000-0000-0000-0000-000000000001",
+ "DeleteSemanticLayerCommand",
+ ),
+ ],
+)
+def test_semantic_delete_reports_dependents_as_conflict(
+ client: Any,
+ full_api_access: None,
+ mocker: MockerFixture,
+ path: str,
+ command: str,
+) -> None:
+ """Every REST delete entry point returns the bounded conflict details."""
+ import prison as rison_lib
+
+ dependent: dict[str, str | int] = {
+ "type": "chart",
+ "id": 7,
+ "name": "Revenue",
+ }
+ mock_command: MagicMock = mocker.patch(f"superset.semantic_layers.api.{command}")
+ mock_command.return_value.run.side_effect = SemanticDeleteDependentsError(
+ 25, [dependent], 24
+ )
+
+ request_path: str = (
+ f"/api/v1/semantic_view/?q={rison_lib.dumps([1, 2])}"
+ if command == "BulkDeleteSemanticViewCommand"
+ else path
+ )
+ response: TestResponse = client.delete(request_path)
+
+ assert response.status_code == 409
+ assert response.json["total"] == 25
+ assert response.json["dependents"] == [dependent]
+ assert response.json["inaccessible_count"] == 24
+ assert response.json["message"] == (
+ "Semantic source is used by 25 dependent assets and cannot be deleted."
+ )
+
+
+@pytest.mark.parametrize(
+ ("api_class", "method"),
+ [
+ (SemanticViewRestApi, "delete"),
+ (SemanticViewRestApi, "bulk_delete"),
+ (SemanticLayerRestApi, "delete"),
+ ],
+)
+def test_semantic_delete_openapi_conflict_schema(
+ api_class: type[SemanticViewRestApi] | type[SemanticLayerRestApi], method: str
+) -> None:
+ """The generated operation documents every field in the 409 response."""
+ api: SemanticViewRestApi | SemanticLayerRestApi = object.__new__(api_class)
+ operations: dict[str, Any] = {}
+ api.operation_helper(
+ path="/api/v1/semantic_source/delete",
+ operations=operations,
+ methods=["DELETE"],
+ func=getattr(api, method),
+ )
+ schema: dict[str, Any] = operations["delete"]["responses"][409]["content"][
+ "application/json"
+ ]["schema"]
+ assert set(schema["required"]) == {
+ "message",
+ "total",
+ "dependents",
+ "inaccessible_count",
+ }
+ assert schema["properties"]["dependents"]["maxItems"] == 20
+ assert schema["properties"]["dependents"]["items"]["properties"]["type"][
+ "enum"
+ ] == ["chart", "dashboard", "alert", "report"]
+
+
@SEMANTIC_LAYERS_APP
def test_get_list_semantic_layers(
client: Any,