Files
superset2/tests/unit_tests/dao/dataset_test.py
Mike Bridge 04f8b700d7 feat(datasets): soft-delete and restore (#40130)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:57:08 -07:00

361 lines
12 KiB
Python

# 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 copy
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from freezegun import freeze_time
from sqlalchemy.orm.session import Session
from superset.daos.base import BaseDAO
from superset.daos.dataset import DatasetDAO
from superset.sql.parse import Table
def test_validate_update_uniqueness(session: Session) -> None:
"""
Test the `validate_update_uniqueness` static method.
In particular, allow datasets with the same name in the same database as long as they
are in different schemas
""" # noqa: E501
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(session.get_bind())
database = Database(
database_name="my_db",
sqlalchemy_uri="sqlite://",
)
dataset1 = SqlaTable(
table_name="my_dataset",
schema="main",
database=database,
)
dataset2 = SqlaTable(
table_name="my_dataset",
schema="dev",
database=database,
)
db.session.add_all([database, dataset1, dataset2])
db.session.flush()
assert (
DatasetDAO.validate_update_uniqueness(
database=database,
table=Table(dataset1.table_name, dataset1.schema),
dataset_id=dataset1.id,
)
is True
)
assert (
DatasetDAO.validate_update_uniqueness(
database=database,
table=Table(dataset1.table_name, dataset2.schema),
dataset_id=dataset1.id,
)
is False
)
assert (
DatasetDAO.validate_update_uniqueness(
database=database,
table=Table(dataset1.table_name),
dataset_id=dataset1.id,
)
is True
)
def test_logical_duplicate_catalog_predicate_is_null_aware(session: Session) -> None:
"""A row stored with ``catalog = NULL`` is the default catalog, so a probe
normalized to the database default must still match it.
Before the null-aware predicate the stored ``catalog`` was compared as-is,
so a ``catalog = NULL`` row and a ``catalog = <default>`` probe were treated
as different physical tables and a default-catalog twin slipped through.
"""
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(session.get_bind())
database = Database(database_name="cat_db", sqlalchemy_uri="sqlite://")
stored_null = SqlaTable(
table_name="t", schema="main", catalog=None, database=database
)
db.session.add_all([database, stored_null])
db.session.flush()
# Pretend the database exposes a non-NULL default catalog so the probe
# normalizes to it; the NULL-stored row must still be recognized as a twin.
with patch.object(Database, "get_default_catalog", return_value="default_cat"):
probe = SqlaTable(
table_name="t", schema="main", catalog="default_cat", database=database
)
db.session.add(probe)
db.session.flush()
assert DatasetDAO.has_active_logical_duplicate(probe) is True
# A genuinely different (non-default) catalog is not a twin.
other = SqlaTable(
table_name="t", schema="main", catalog="other_cat", database=database
)
db.session.add(other)
db.session.flush()
assert DatasetDAO.has_active_logical_duplicate(other) is False
def test_find_soft_deleted_logical_duplicate(session: Session) -> None:
"""The importer create-path helper returns a soft-deleted twin (despite the
visibility filter) and ignores active rows."""
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
SqlaTable.metadata.create_all(session.get_bind())
database = Database(database_name="sd_db", sqlalchemy_uri="sqlite://")
twin = SqlaTable(table_name="t", schema="main", database=database)
db.session.add_all([database, twin])
db.session.flush()
table = Table("t", "main")
# Active row: not a soft-deleted twin.
assert DatasetDAO.find_soft_deleted_logical_duplicate(database, table) is None
# Soft-delete it: now found despite the visibility filter.
twin.deleted_at = datetime(2026, 1, 1, 12, 0, 0)
db.session.flush()
found = DatasetDAO.find_soft_deleted_logical_duplicate(database, table)
assert found is not None
assert found.id == twin.id
# Catalog mismatch: a soft-deleted row in a *different* (non-default)
# catalog is not the same physical table, so the null-aware predicate must
# exclude it. (sqlite has no default catalog, so an explicit "other_cat"
# row must only match an "other_cat" probe.)
other_catalog_twin = SqlaTable(
table_name="t2",
schema="main",
catalog="other_cat",
database=database,
deleted_at=datetime(2026, 1, 1, 12, 0, 0),
)
db.session.add(other_catalog_twin)
db.session.flush()
assert (
DatasetDAO.find_soft_deleted_logical_duplicate(
database, Table("t2", "main", "my_cat")
)
is None
)
# Same explicit catalog: found.
same_catalog = DatasetDAO.find_soft_deleted_logical_duplicate(
database, Table("t2", "main", "other_cat")
)
assert same_catalog is not None
assert same_catalog.id == other_catalog_twin.id
@freeze_time("2025-01-01 00:00:00")
@patch.object(DatasetDAO, "update_columns")
@patch.object(DatasetDAO, "update_metrics")
@patch.object(BaseDAO, "update")
@pytest.mark.parametrize(
"attributes,expected_attributes",
[
(
{
"columns": [{"id": 1, "name": "col1"}],
"metrics": [{"id": 1, "name": "metric1"}],
},
{"changed_on": datetime(2025, 1, 1, 0, 0, 0)},
),
(
{
"columns": [{"id": 1, "name": "col1"}],
"metrics": [{"id": 1, "name": "metric1"}],
"description": "test description",
},
{
"description": "test description",
"changed_on": datetime(2025, 1, 1, 0, 0, 0),
},
),
(
{
"columns": [{"id": 1, "name": "col1"}],
},
{"changed_on": datetime(2025, 1, 1, 0, 0, 0)},
),
(
{
"columns": [{"id": 1, "name": "col1"}],
"description": "test description",
},
{
"description": "test description",
"changed_on": datetime(2025, 1, 1, 0, 0, 0),
},
),
(
{
"metrics": [{"id": 1, "name": "metric1"}],
},
{"changed_on": datetime(2025, 1, 1, 0, 0, 0)},
),
(
{
"metrics": [{"id": 1, "name": "metric1"}],
"description": "test description",
},
{
"description": "test description",
"changed_on": datetime(2025, 1, 1, 0, 0, 0),
},
),
(
{"description": "test description"},
{"description": "test description"},
),
],
)
def test_update_dataset_related_metadata_updates_changed_on(
base_update_mock: MagicMock,
update_metrics_mock: MagicMock,
update_columns_mock: MagicMock,
attributes: dict[str, Any],
expected_attributes: dict[str, Any],
) -> None:
"""
Test that the changed_on property is updated when a metric or column is updated.
"""
item = MagicMock()
DatasetDAO.update(item, copy.deepcopy(attributes))
if "columns" in attributes:
update_columns_mock.assert_called_once_with(
item, attributes["columns"], override_columns=False
)
else:
update_columns_mock.assert_not_called()
if "metrics" in attributes:
update_metrics_mock.assert_called_once_with(item, attributes["metrics"])
else:
update_metrics_mock.assert_not_called()
base_update_mock.assert_called_once_with(item, expected_attributes)
def _mock_dataset(catalog: str | None, default_catalog: str) -> MagicMock:
"""A SqlaTable-shaped mock with controllable catalog values."""
model = MagicMock()
model.id = 1
model.database_id = 7
model.schema = "public"
model.table_name = "users"
model.catalog = catalog
model.database.get_default_catalog.return_value = default_catalog
return model
def test_has_active_logical_duplicate_normalizes_unset_catalog(
app_context: None,
) -> None:
"""A row stored with ``catalog = None`` is matched against the database
default catalog, the same rule ``validate_uniqueness`` applies.
This is the gap the soft-delete reviews flagged: restore/re-import
compared the raw stored ``catalog`` while create/update normalized it, so
a soft-deleted ``catalog = NULL`` row and an active default-catalog twin
could be treated as different physical tables.
"""
model = _mock_dataset(catalog=None, default_catalog="default_cat")
with patch("superset.daos.dataset.db") as mock_db:
mock_db.session.query.return_value.filter.return_value.first.return_value = None
assert DatasetDAO.has_active_logical_duplicate(model) is False
# Normalization must consult the database default when catalog is unset.
model.database.get_default_catalog.assert_called_once()
def test_has_active_logical_duplicate_keeps_explicit_catalog(
app_context: None,
) -> None:
"""An explicit catalog is matched exactly, but the database default is still
consulted: the null-aware predicate needs it to decide whether the explicit
catalog *is* the default (and should therefore also match ``catalog IS
NULL`` rows)."""
model = _mock_dataset(catalog="explicit_cat", default_catalog="default_cat")
with patch("superset.daos.dataset.db") as mock_db:
mock_db.session.query.return_value.filter.return_value.first.return_value = None
assert DatasetDAO.has_active_logical_duplicate(model) is False
model.database.get_default_catalog.assert_called_once()
def test_has_active_logical_duplicate_true_when_twin_found(
app_context: None,
) -> None:
"""A matching active row makes the helper report a duplicate."""
model = _mock_dataset(catalog="explicit_cat", default_catalog="default_cat")
with patch("superset.daos.dataset.db") as mock_db:
mock_db.session.query.return_value.filter.return_value.first.return_value = (
MagicMock()
)
assert DatasetDAO.has_active_logical_duplicate(model) is True
def test_has_active_logical_duplicate_never_bypasses_visibility(
app_context: None,
) -> None:
"""Contract tripwire for the docstring's do-not-add-the-bypass clause.
The helper's active-rows-only semantics come from the SoftDeleteMixin
listener; wrapping the query in ``skip_visibility_filter`` — the file's
dominant pattern, so a plausible future "consistency" edit — would
broaden the check to soft-deleted rows and silently refuse legitimate
restores of legacy twin pairs. The suite stays green under that
mutation without this pin.
"""
model = _mock_dataset(catalog=None, default_catalog="default_cat")
with (
patch("superset.daos.dataset.db") as mock_db,
patch("superset.models.helpers.skip_visibility_filter") as bypass_spy,
):
mock_db.session.query.return_value.filter.return_value.first.return_value = None
DatasetDAO.has_active_logical_duplicate(model)
bypass_spy.assert_not_called()