Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/static/feature-flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
"lifecycle": "development",
"description": "Enable semantic layers and show semantic views alongside datasets"
},
{
"name": "SOFT_DELETE",
"default": false,
"lifecycle": "development",
"description": "Temporary rollout / kill-switch gate for soft delete. Default off. When off: DELETE hard-deletes (legacy) and the read-path visibility filter is inactive, so the substrate ships dark; the import pipeline's existing-row detection inherits this via the same listener (no separate check); restore has nothing to act on because no row carries deleted_at. Flipping ON->OFF after rows were soft-deleted RESURRECTS them (they become visible/active again) \u2014 an emergency stop, not a clean rollback. Not a permanent toggle: REMOVE this flag and its two gate points (BaseDAO.delete routing; the do_orm_execute visibility listener) once soft delete is stable."
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
"default": false,
Expand Down
12 changes: 12 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,18 @@ class D3TimeFormat(TypedDict, total=False):
# can_copy_clipboard) instead of the single can_csv permission
# @lifecycle: development
"GRANULAR_EXPORT_CONTROLS": False,
# Temporary rollout / kill-switch gate for soft delete. Default off. When
# off: DELETE hard-deletes (legacy) and
# the read-path visibility filter is inactive, so the substrate ships dark;
# the import pipeline's existing-row detection inherits this via the same
# listener (no separate check); restore has nothing to act on because no row
# carries deleted_at. Flipping ON->OFF after rows were soft-deleted RESURRECTS
# them (they become visible/active again) — an emergency stop, not a clean
# rollback. Not a permanent toggle: REMOVE this flag and its two gate points
# (BaseDAO.delete routing; the do_orm_execute visibility listener) once soft
# delete is stable.
# @lifecycle: development
"SOFT_DELETE": False,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
"SEMANTIC_LAYERS": False,
Expand Down
15 changes: 11 additions & 4 deletions superset/daos/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from superset_core.common.daos import BaseDAO as CoreBaseDAO
from superset_core.common.models import CoreModel

from superset import is_feature_enabled
from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.daos.exceptions import (
DAOFindFailedError,
Expand Down Expand Up @@ -516,16 +517,22 @@ def delete(cls, items: list[T]) -> None:
soft delete.

For models that include ``SoftDeleteMixin``, this calls
``soft_delete()``. For all other models, this calls ``hard_delete()``
(the original behaviour).
``soft_delete()`` — but only while the temporary ``SOFT_DELETE`` rollout
gate is enabled. When the gate is off, every model
hard-deletes (the original behaviour), so the substrate can ship dark.
For all other models, this always calls ``hard_delete()``.

:param items: The items to delete
"""
from superset.models.helpers import (
SoftDeleteMixin, # pylint: disable=import-outside-toplevel
SoftDeleteMixin, # avoid circular import: models.helpers <-> daos
)

if cls.model_cls is not None and issubclass(cls.model_cls, SoftDeleteMixin):
if (
cls.model_cls is not None
and issubclass(cls.model_cls, SoftDeleteMixin)
and is_feature_enabled("SOFT_DELETE")
):
cls.soft_delete(items)
else:
cls.hard_delete(items)
Expand Down
11 changes: 10 additions & 1 deletion superset/models/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,17 @@ def _should_attach_soft_delete_criteria(execute_state: ORMExecuteState) -> bool:
relationship-load event closes that gap. The resulting WHERE
clause may have ``deleted_at IS NULL`` twice when propagation
DOES work — harmless redundancy, idempotent SQL.

Gated by the temporary ``SOFT_DELETE`` rollout flag: while it is
off, no criteria are attached, so soft-deleted rows (which the delete path
does not create while the flag is off) are not filtered. The flag and this
gate are removed once soft delete is stable.
"""
return execute_state.is_select and not execute_state.is_column_load
return (
execute_state.is_select
and not execute_state.is_column_load
and is_feature_enabled("SOFT_DELETE")
)


def _all_soft_delete_subclasses() -> list[type[SoftDeleteMixin]]:
Expand Down
28 changes: 24 additions & 4 deletions tests/unit_tests/daos/test_base_dao_soft_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,37 @@ class _PlainDAO(BaseDAO[_Plain]):
model_cls = _Plain


def test_delete_routes_to_soft_delete_for_mixin_models(app_context: None) -> None:
"""delete() calls soft_delete() when model_cls includes SoftDeleteMixin."""
@patch("superset.daos.base.is_feature_enabled", return_value=True)
def test_delete_routes_to_soft_delete_for_mixin_models(
mock_flag: MagicMock, app_context: None
) -> None:
"""delete() soft-deletes a mixin model when the SOFT_DELETE gate is ON."""
items = [MagicMock(), MagicMock()]

with patch.object(_SoftDeletableDAO, "soft_delete") as mock_soft:
_SoftDeletableDAO.delete(items)
mock_soft.assert_called_once_with(items)


def test_delete_routes_to_hard_delete_for_non_mixin_models(app_context: None) -> None:
"""delete() calls hard_delete() for non-SoftDeleteMixin models."""
@patch("superset.daos.base.is_feature_enabled", return_value=False)
def test_delete_hard_deletes_mixin_model_when_gate_off(
mock_flag: MagicMock, app_context: None
) -> None:
"""With the SOFT_DELETE gate OFF (default), even a mixin model hard-deletes
— the substrate ships dark."""
items = [MagicMock(), MagicMock()]

with patch.object(_SoftDeletableDAO, "hard_delete") as mock_hard:
_SoftDeletableDAO.delete(items)
mock_hard.assert_called_once_with(items)


@patch("superset.daos.base.is_feature_enabled", return_value=True)
def test_delete_routes_to_hard_delete_for_non_mixin_models(
mock_flag: MagicMock, app_context: None
) -> None:
"""delete() calls hard_delete() for non-SoftDeleteMixin models — regardless
of the gate (here ON, to show the gate doesn't make a plain model soft)."""
items = [MagicMock(), MagicMock()]

with patch.object(_PlainDAO, "hard_delete") as mock_hard:
Expand Down
38 changes: 38 additions & 0 deletions tests/unit_tests/models/test_soft_delete_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

from collections.abc import Generator
from datetime import datetime
from unittest.mock import patch

import pytest
from sqlalchemy import Column, ForeignKey, Integer, String
Expand Down Expand Up @@ -90,6 +91,18 @@ def _synthetic_tables(session: Session) -> Generator[None, None, None]:
_TestBase.metadata.drop_all(session.get_bind())


@pytest.fixture(autouse=True)
def _soft_delete_gate_on() -> Generator[None, None, None]:
"""The ``do_orm_execute`` visibility listener is gated by the temporary
``SOFT_DELETE`` rollout flag, default off. These tests exercise
the listener's filtering, so enable the gate for the whole module. The
gate-off (listener-noop) behaviour is pinned separately by
``test_listener_noop_when_gate_off``.
"""
with patch("superset.models.helpers.is_feature_enabled", return_value=True):
yield


@pytest.mark.usefixtures("_synthetic_tables")
def test_soft_delete_sets_deleted_at(app_context: None, session: Session) -> None:
"""soft_delete() sets deleted_at to a non-null datetime."""
Expand Down Expand Up @@ -167,6 +180,31 @@ def test_global_filter_excludes_soft_deleted_rows(
assert result is None


@pytest.mark.usefixtures("_synthetic_tables")
def test_listener_noop_when_gate_off(app_context: None, session: Session) -> None:
"""With the ``SOFT_DELETE`` gate OFF, the listener attaches no criteria, so a
soft-deleted row is NOT hidden — the substrate is dark. (While
the gate is off the delete path also doesn't create such rows; this pins the
listener side.)"""
obj = _SoftDeletable(name="visible_when_gate_off")
session.add(obj)
session.flush()
obj_id = obj.id

obj.soft_delete()
session.flush()
session.expire_all()

with patch("superset.models.helpers.is_feature_enabled", return_value=False):
result = (
session.query(_SoftDeletable)
.filter(_SoftDeletable.id == obj_id)
.one_or_none()
)
assert result is not None
assert result.id == obj_id


@pytest.mark.usefixtures("_synthetic_tables")
def test_listener_adapts_criteria_to_aliased_table_in_joins(
app_context: None, session: Session
Expand Down
Loading