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,