mirror of
https://github.com/apache/superset.git
synced 2026-09-09 08:44:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c02e15c03 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user