Compare commits

...
Author SHA1 Message Date
geido 4c02e15c03 fix(database): surface dataset dependents in the delete confirmation
The delete confirmation for a database connection enumerated only charts,
dashboards and SQL Lab tabs. Datasets were absent from
DatabaseDAO.get_related_objects and from the related_objects response, so a
connection whose only dependents are datasets rendered as "0 charts that
appear on 0 dashboards ... 0 SQL Lab tabs" and then asked the user to confirm.

DeleteDatabaseCommand refuses that delete outright rather than cascading, so
the confirmation was inviting an operation that could not succeed: the user
typed DELETE and got a 422 danger toast they had no way to anticipate.

Report datasets in related_objects, name them in the modal, and state that the
delete is blocked instead of asking for a confirmation. Soft-deleted datasets
are counted separately: Database.tables hides them, but they still hold the
reference that blocks the delete, which is exactly the state a user lands in
right after deleting the last dataset.
2026-08-31 09:39:16 +00:00
6 changed files with 436 additions and 4 deletions
@@ -0,0 +1,204 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { configureStore } from '@reduxjs/toolkit';
import { render, screen, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import DatabaseList from 'src/pages/DatabaseList';
/**
* The backend refuses to delete a database while any dataset still references
* it, so the delete confirmation has to name those datasets up front. These
* tests pin that preview: without it the modal reports only charts, dashboards
* and SQL Lab tabs, reads as "nothing is attached" for a connection whose only
* dependents are datasets, and the delete the user then confirms fails with a
* 422 they had no way to anticipate.
*/
const DATABASE_ID = 1;
const databaseRow = {
id: DATABASE_ID,
database_name: 'qa_warehouse',
backend: 'postgresql',
allow_run_async: false,
allow_dml: false,
allow_file_upload: false,
expose_in_sqllab: true,
changed_on_delta_humanized: 'a day ago',
changed_by: null,
};
const RELATED_OBJECTS_ROUTE = `glob:*/api/v1/database/${DATABASE_ID}/related_objects/*`;
const mockUser = {
userId: 1,
firstName: 'Admin',
lastName: 'User',
roles: { Admin: [['can_write', 'Database']] },
permissions: {},
isActive: true,
email: 'admin@example.com',
createdOn: '2026-01-01T00:00:00',
};
const setupMocks = (datasets: {
count: number;
result: { id: number; table_name: string }[];
soft_deleted_count: number;
}) => {
fetchMock.clearHistory().removeRoutes();
fetchMock.get('glob:*/api/v1/database/_info*', {
permissions: ['can_read', 'can_write', 'can_export'],
});
fetchMock.get('glob:*/api/v1/database/?q=*', {
result: [databaseRow],
count: 1,
});
fetchMock.get('glob:*/api/v1/database/related/*', { result: [], count: 0 });
fetchMock.get(RELATED_OBJECTS_ROUTE, {
charts: { count: 0, result: [] },
dashboards: { count: 0, result: [] },
sqllab_tab_states: { count: 0, result: [] },
datasets,
});
fetchMock.delete(`glob:*/api/v1/database/${DATABASE_ID}`, {});
};
const renderDatabaseList = () => {
const store = configureStore({
reducer: {
user: (state = mockUser) => state,
common: (
state = {
conf: {
CSV_EXTENSIONS: ['csv'],
EXCEL_EXTENSIONS: ['xls'],
COLUMNAR_EXTENSIONS: ['parquet'],
ALLOWED_EXTENSIONS: ['csv', 'xls', 'parquet'],
SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: false,
},
},
) => state,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }),
});
return render(<DatabaseList user={mockUser} />, {
store,
useQueryParams: true,
useRouter: true,
});
};
const openDeleteModal = async () => {
const deleteButton = await screen.findByTestId('database-delete');
await userEvent.click(deleteButton);
return screen.findByRole('dialog');
};
afterEach(() => {
fetchMock.clearHistory();
fetchMock.removeRoutes();
});
test('delete confirmation counts and names the datasets built on the connection', async () => {
setupMocks({
count: 2,
result: [
{ id: 10, table_name: 'qa_orders' },
{ id: 11, table_name: 'qa_customers' },
],
soft_deleted_count: 0,
});
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'is linked to 2 datasets and 0 charts that appear on 0 dashboards, and users have 0 SQL Lab tabs using this database open.',
{ exact: false },
),
).toBeInTheDocument();
expect(within(dialog).getByText('Affected Datasets')).toBeInTheDocument();
expect(within(dialog).getByText('qa_orders')).toBeInTheDocument();
expect(within(dialog).getByText('qa_customers')).toBeInTheDocument();
});
test('a connection with datasets says the delete is blocked rather than inviting a confirmation that cannot succeed', async () => {
setupMocks({
count: 1,
result: [{ id: 10, table_name: 'qa_orders' }],
soft_deleted_count: 0,
});
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This database cannot be deleted until its datasets are removed.',
),
).toBeInTheDocument();
// The backend rejects this delete outright, so the modal must not claim the
// connection is merely about to break the objects listed.
expect(
within(dialog).queryByText(
'Are you sure you want to continue? Deleting the database will break those objects.',
),
).not.toBeInTheDocument();
});
test('deleted datasets still referencing the connection are warned about even though they are no longer listed', async () => {
// Datasets are soft-deleted by default, so this is the state a user lands in
// right after deleting the last dataset: their dataset list looks empty, but
// the reference survives and keeps blocking the connection's delete.
setupMocks({ count: 0, result: [], soft_deleted_count: 2 });
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This database cannot be deleted yet: 2 deleted datasets still reference it. Their names no longer appear in the dataset list, but the references have to be cleared before the connection can be removed.',
),
).toBeInTheDocument();
expect(
within(dialog).queryByText('Affected Datasets'),
).not.toBeInTheDocument();
});
test('a connection with no datasets keeps the ordinary confirmation prompt', async () => {
setupMocks({ count: 0, result: [], soft_deleted_count: 0 });
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'Are you sure you want to continue? Deleting the database will break those objects.',
{ exact: false },
),
).toBeInTheDocument();
expect(
within(dialog).queryByText('Affected Datasets'),
).not.toBeInTheDocument();
});
@@ -96,10 +96,22 @@ type ConnectionItem = DatabaseObject & {
changed_on_delta_humanized?: string;
};
interface DatabaseRelatedDatasets {
count: number;
result: { id: number; table_name: string }[];
/**
* Datasets that were deleted but still reference the connection. They are
* hidden from the dataset list yet still block the connection's delete, so
* the confirmation accounts for them separately from `count`.
*/
soft_deleted_count: number;
}
interface DatabaseDeleteObject extends DatabaseObject {
charts: any;
dashboards: any;
sqllab_tab_count: number;
datasets: DatabaseRelatedDatasets;
}
/** How many dependent semantic views the delete confirmation lists by name. */
@@ -376,6 +388,7 @@ function DatabaseList({
charts: json.charts,
dashboards: json.dashboards,
sqllab_tab_count: json.sqllab_tab_states.count,
datasets: json.datasets,
});
})
.catch(
@@ -1010,6 +1023,16 @@ function DatabaseList({
return baseFilters;
}, [showSemanticLayers]);
// A connection with any dataset still attached cannot be deleted at all: the
// backend refuses with a 422 rather than cascading. Soft-deleted datasets
// count too — they stay hidden from the dataset list but keep the reference
// alive — so the confirmation has to warn about them even though it cannot
// name them usefully.
const datasetsBlockingDelete = databaseCurrentlyDeleting
? databaseCurrentlyDeleting.datasets.count +
databaseCurrentlyDeleting.datasets.soft_deleted_count
: 0;
return (
<>
<SubMenu {...menuData} />
@@ -1102,12 +1125,77 @@ function DatabaseList({
{t('The %s', databaseLabelLower())}{' '}
<b>{databaseCurrentlyDeleting.database_name}</b>{' '}
{t(
'is linked to %s charts that appear on %s dashboards and users have %s SQL Lab tabs using this database open. Are you sure you want to continue? Deleting the database will break those objects.',
'is linked to %s datasets and %s charts that appear on %s dashboards, and users have %s SQL Lab tabs using this database open.',
databaseCurrentlyDeleting.datasets.count,
databaseCurrentlyDeleting.charts.count,
databaseCurrentlyDeleting.dashboards.count,
databaseCurrentlyDeleting.sqllab_tab_count,
)}
)}{' '}
{datasetsBlockingDelete === 0 &&
t(
'Are you sure you want to continue? Deleting the database will break those objects.',
)}
</p>
{datasetsBlockingDelete > 0 && (
<p>
<b>
{databaseCurrentlyDeleting.datasets.count > 0
? t(
'This %s cannot be deleted until its datasets are removed.',
databaseLabelLower(),
)
: t(
'This %s cannot be deleted yet: %s deleted datasets still reference it. Their names no longer appear in the dataset list, but the references have to be cleared before the connection can be removed.',
databaseLabelLower(),
databaseCurrentlyDeleting.datasets.soft_deleted_count,
)}
</b>
</p>
)}
{databaseCurrentlyDeleting.datasets.count >= 1 && (
<>
<h4>{t('Affected Datasets')}</h4>
<List
split={false}
size="small"
dataSource={databaseCurrentlyDeleting.datasets.result.slice(
0,
10,
)}
renderItem={(result: {
id: number;
table_name: string;
}) => (
<List.Item key={result.id} compact>
<List.Item.Meta
avatar={<span></span>}
title={
<Typography.Link
href={ensureAppRoot(
`/explore/?datasource_type=table&datasource_id=${result.id}`,
)}
target="_atRiskItem"
>
{result.table_name}
</Typography.Link>
}
/>
</List.Item>
)}
footer={
databaseCurrentlyDeleting.datasets.result.length > 10 && (
<div>
{t(
'... and %s others',
databaseCurrentlyDeleting.datasets.result.length -
10,
)}
</div>
)
}
/>
</>
)}
{databaseCurrentlyDeleting.dashboards.count >= 1 && (
<>
<h4>{t('Affected Dashboards')}</h4>
+19
View File
@@ -230,10 +230,29 @@ class DatabaseDAO(BaseDAO[Database]):
db.session.query(TabState).filter(TabState.database_id == database_id).all()
)
# ``database.tables`` only sees live datasets: ``SqlaTable`` inherits
# ``SoftDeleteMixin``, so the relationship load applies the visibility
# filter. ``DeleteDatabaseCommand`` blocks the delete on soft-deleted
# rows too (they still FK-reference the database), so look those up with
# the filter bypassed. Without them a database whose datasets were all
# deleted reports zero dependents here and then fails the delete with a
# 422 the caller had no way to anticipate.
soft_deleted_datasets = (
db.session.query(SqlaTable)
.filter(
SqlaTable.database_id == database_id,
SqlaTable.deleted_at.is_not(None),
)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {SqlaTable}})
.all()
)
return {
"charts": charts,
"dashboards": dashboards,
"sqllab_tab_states": sqllab_tab_states,
"datasets": datasets,
"soft_deleted_datasets": soft_deleted_datasets,
}
@classmethod
+15 -2
View File
@@ -1337,10 +1337,10 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
log_to_statsd=False,
)
def related_objects(self, pk: int) -> Response:
"""Get charts and dashboards count associated to a database.
"""Get charts, dashboards and datasets count associated to a database.
---
get:
summary: Get charts and dashboards count associated to a database
summary: Get charts, dashboards and datasets count associated to a database
parameters:
- in: path
name: pk
@@ -1387,6 +1387,14 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
{"id": tab_state.id, "label": tab_state.label, "active": tab_state.active}
for tab_state in data["sqllab_tab_states"]
]
# Datasets are reported unfiltered, unlike charts and dashboards above:
# every one of them blocks ``DeleteDatabaseCommand``, so hiding the ones
# the caller cannot open would under-report why the delete is refused.
# Reaching this route already requires access to the parent database.
datasets = [
{"id": dataset.id, "table_name": dataset.table_name}
for dataset in data["datasets"]
]
return self.response(
200,
charts={"count": len(charts), "result": charts},
@@ -1395,6 +1403,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
"count": len(sqllab_tab_states),
"result": sqllab_tab_states,
},
datasets={
"count": len(datasets),
"result": datasets,
"soft_deleted_count": len(data["soft_deleted_datasets"]),
},
)
@expose("/<int:pk>/validate_sql/", methods=("POST",))
+22
View File
@@ -929,9 +929,31 @@ class DatabaseRelatedDashboards(Schema):
)
class DatabaseRelatedDataset(Schema):
id = fields.Integer()
table_name = fields.String()
class DatabaseRelatedDatasets(Schema):
count = fields.Integer(metadata={"description": "Live dataset count"})
result = fields.List(
fields.Nested(DatabaseRelatedDataset),
metadata={"description": "A list of datasets"},
)
soft_deleted_count = fields.Integer(
metadata={
"description": (
"Soft-deleted datasets that still reference the database. They "
"are hidden from the dataset list but continue to block deletion."
)
}
)
class DatabaseRelatedObjectsResponse(Schema):
charts = fields.Nested(DatabaseRelatedCharts)
dashboards = fields.Nested(DatabaseRelatedDashboards)
datasets = fields.Nested(DatabaseRelatedDatasets)
class DatabaseFunctionNamesResponse(Schema):
+86
View File
@@ -2670,3 +2670,89 @@ def test_import_includes_configuration_method(
f"'configuration_method' not found in database list response: {db_obj_api}"
)
assert db_obj_api["configuration_method"] == "dynamic_form"
def test_related_objects_reports_attached_datasets(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""``related_objects`` must report the datasets attached to a connection.
``DeleteDatabaseCommand`` refuses to delete a database while any dataset
still references it. When the preview behind the delete confirmation omits
datasets, a connection whose only dependents are datasets reads as having
nothing attached, and the delete the operator then confirms fails with a
422 they had no way to anticipate.
"""
from superset.connectors.sqla.models import SqlaTable
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel._session = session
SqlaTable.metadata.create_all(session.get_bind()) # pylint: disable=no-member
database = Database(database_name="related_db", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
db.session.add_all(
[
SqlaTable(table_name="qa_orders", database=database),
SqlaTable(
table_name="qa_archived",
database=database,
deleted_at=datetime(2026, 1, 1, 12, 0, 0),
),
]
)
db.session.commit()
response = client.get(f"/api/v1/database/{database.id}/related_objects/")
assert response.status_code == 200
datasets = response.json["datasets"]
assert datasets["count"] == 1
assert [dataset["table_name"] for dataset in datasets["result"]] == ["qa_orders"]
# The soft-deleted row is hidden from the dataset list, but it still holds a
# reference that blocks the delete, so it is reported separately instead of
# being dropped from the preview entirely.
assert datasets["soft_deleted_count"] == 1
def test_related_objects_reports_soft_deleted_only_datasets(
session: Session,
client: Any,
full_api_access: None,
) -> None:
"""A connection whose datasets were all deleted still cannot be deleted.
This is the case the preview is most likely to get wrong: ``Database.tables``
hides soft-deleted datasets, so without a separate count the response says
the connection has no dependents at all while the delete stays blocked.
"""
from superset.connectors.sqla.models import SqlaTable
from superset.databases.api import DatabaseRestApi
from superset.models.core import Database
DatabaseRestApi.datamodel._session = session
SqlaTable.metadata.create_all(session.get_bind()) # pylint: disable=no-member
database = Database(database_name="soft_deleted_db", sqlalchemy_uri="sqlite://")
db.session.add(database)
db.session.flush()
db.session.add(
SqlaTable(
table_name="qa_archived",
database=database,
deleted_at=datetime(2026, 1, 1, 12, 0, 0),
)
)
db.session.commit()
response = client.get(f"/api/v1/database/{database.id}/related_objects/")
assert response.status_code == 200
datasets = response.json["datasets"]
assert datasets["count"] == 0
assert datasets["result"] == []
assert datasets["soft_deleted_count"] == 1