Skip to content

Commit 53962ca

Browse files
Mike Bridgeclaude
andcommitted
refactor(soft-delete): address aminghadersohi review on PR apache#39977
Eight items from the 2026-05-20 review on the infrastructure PR: * **H1 — subclass walk cached via ``__init_subclass__``.** The previous ``_all_soft_delete_subclasses()`` walked ``SoftDeleteMixin.__subclasses__()`` on every primary ORM SELECT. With one adopter today the cost is negligible, but the listener fires on every query in the app and the walk grows linearly with each adopted entity. ``SoftDeleteMixin`` now maintains a sorted ``_registered_subclasses`` list updated by ``__init_subclass__`` at class-definition time; the listener reads the cached list with no walk. Sort happens once per registration (rare event) rather than per query. * **M3 — ``SoftDeleteMixin`` import in ``BaseDAO.delete`` moved to top.** ``SKIP_VISIBILITY_FILTER_CLASSES`` from the same module was already top-level; ``SoftDeleteMixin`` had no real reason to be inline. Cleaner. * **M4 — pylint-disable in ``raise_for_ownership`` documented.** A top-level import here actually does cause a circular (security ↔ models.core ↔ superset's lazy ``feature_flag_manager``). Kept the inline import but added a comment naming the cycle so future maintainers don't try to "fix" it and re-trip the issue. * **M5 — ``BaseDeletedStateFilter.model`` typed as ``ClassVar[type[SoftDeleteMixin]]``.** Previously ``Any``, which meant a subclass accidentally binding ``model`` to a non-soft-deletable entity would crash at runtime on ``.deleted_at`` rather than failing mypy. * **M6 — listener placement documented.** ``setup_soft_delete_listener`` is called outside the ``with app_context()`` block because the ``do_orm_execute`` hook attaches to the ``Session`` class (process-wide), not to a Session instance, so it doesn't need Flask app context. Added an explaining comment in ``init_app_in_ctx``'s caller; ``setup_db()`` already ran earlier so the Session import is initialised. * **M7 — unit tests for ``BaseDeletedStateFilter`` + ``SoftDeleteApiMixin``.** New ``tests/unit_tests/views/test_soft_delete_filter.py`` (10 tests): filter no-op / include / only / case-insensitivity / unknown value; mixin no-op-without-flag / inject-with-flag / consume-flag-on-call; ``_serialize_deleted_at`` None / datetime handling. * **Nit — explicit ``orig_resource is None`` guard in ``raise_for_ownership``.** Previously fell through to ``hasattr(None, "owners")`` returning ``False`` → ``owners = []`` → generic ownership error. Now raises the proper "resource removed before ownership could be verified" error so the cause is clear in the rare race-with-hard-delete case. * **Nit — unit tests for ``find_existing_for_import`` / ``clear_soft_deleted_for_import``.** New ``tests/unit_tests/commands/importers/v1/test_find_existing_for_import.py`` (5 tests): no-match-returns-None, live-row-as-is, soft-deleted-row-as-is (pure lookup, no side effect), ``clear`` hard-deletes via ORM (listener-firing path), and the composed find-then-clear contract that's the documented caller sequence. Deferred with explanation in the PR-comment reply (not in this commit): * **H2 — non-restorable / GDPR hook**: V2 concern. Designing the interface without a concrete use case risks getting it wrong; follow-up ticket if compliance work is ever scoped. * **M8 — ``deleted_at`` schema mutation**: documented in the entity- rollout PR bodies (apache#40128/apache#40129/apache#40130) rather than blocking infrastructure. 40 unit tests pass (was 26; added 14 covering filter, mixin, importer helpers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1c9f25 commit 53962ca

7 files changed

Lines changed: 490 additions & 26 deletions

File tree

superset/daos/base.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
DAOFindFailedError,
4949
)
5050
from superset.extensions import db
51-
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
51+
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES, SoftDeleteMixin
5252

5353
T = TypeVar("T", bound=CoreModel)
5454

@@ -502,8 +502,6 @@ def delete(cls, items: list[T]) -> None:
502502
503503
:param items: The items to delete
504504
"""
505-
from superset.models.helpers import SoftDeleteMixin
506-
507505
if cls.model_cls is not None and issubclass(cls.model_cls, SoftDeleteMixin):
508506
cls.soft_delete(items)
509507
else:

superset/initialization/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,12 @@ def init_app(self) -> None:
767767
with self.superset_app.app_context():
768768
self.init_app_in_ctx()
769769

770+
# Registered outside ``init_app_in_ctx`` because the SQLAlchemy
771+
# event hook attaches to the ``Session`` *class* (a process-wide
772+
# global), not to a Session instance — it has no dependency on
773+
# the Flask app context. ``setup_db()`` ran earlier in
774+
# ``init_app``, so the ``Session`` import has already been
775+
# initialised by the time we get here.
770776
self.setup_soft_delete_listener()
771777
self.post_init()
772778

superset/models/helpers.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Any,
3333
Callable,
3434
cast,
35+
ClassVar,
3536
NamedTuple,
3637
Optional,
3738
TYPE_CHECKING,
@@ -686,8 +687,27 @@ class SoftDeleteMixin:
686687
``execution_options``) or for a session-scoped block (via
687688
``session.info`` / the ``skip_visibility_filter`` context manager).
688689
See ``_add_soft_delete_filter`` for the precise semantics.
690+
691+
Subclass registry: every concrete subclass registers itself in
692+
``_registered_subclasses`` via ``__init_subclass__``. The listener
693+
iterates this cached list rather than walking ``__subclasses__()``
694+
on every primary SELECT — important because the listener fires on
695+
every ORM query in the app, and the walk grows with each adopted
696+
entity.
689697
"""
690698

699+
_registered_subclasses: ClassVar[list[type[SoftDeleteMixin]]] = []
700+
701+
def __init_subclass__(cls, **kwargs: Any) -> None:
702+
super().__init_subclass__(**kwargs)
703+
# Cache the subclass once, sorted by qualname so the listener's
704+
# ``with_loader_criteria`` options attach in a deterministic
705+
# order across processes (stable compiled-statement cache key).
706+
if cls in SoftDeleteMixin._registered_subclasses:
707+
return
708+
SoftDeleteMixin._registered_subclasses.append(cls)
709+
SoftDeleteMixin._registered_subclasses.sort(key=lambda c: c.__qualname__)
710+
691711
deleted_at = sa.Column(sa.DateTime, nullable=True, index=True)
692712

693713
@hybrid_property
@@ -763,29 +783,20 @@ def _is_primary_user_select(execute_state: ORMExecuteState) -> bool:
763783

764784

765785
def _all_soft_delete_subclasses() -> list[type[SoftDeleteMixin]]:
766-
"""All concrete ``SoftDeleteMixin`` subclasses, transitively. Walks
767-
the inheritance tree so an intermediate abstract subclass between
768-
the mixin and a concrete model does not hide leaf classes.
786+
"""The cached subclass registry maintained by
787+
``SoftDeleteMixin.__init_subclass__``. Returned in a stable
788+
qualname-sorted order so SQLAlchemy's compiled-statement cache key
789+
is deterministic across processes.
769790
770-
Assumes all soft-deletable models have been imported by the time the
771-
listener fires. Superset imports models eagerly at app init via
791+
Assumes all soft-deletable models have been imported by the time
792+
the listener fires. Superset imports models eagerly at app init via
772793
``superset.models``; if that ever changes to lazy import, the
773-
listener would silently stop filtering un-imported classes.
774-
775-
Returned in a stable sorted order so SQLAlchemy's compiled-statement
776-
cache key (which incorporates the option chain order) is
777-
deterministic across processes — set iteration is hash-order, and
778-
type hashes derive from ``id()`` which differs per process.
794+
listener would silently stop filtering un-imported classes — but
795+
since registration happens at class-definition time, the cache is
796+
automatically updated as new subclasses are introduced (including
797+
test-defined synthetic subclasses).
779798
"""
780-
seen: set[type] = set()
781-
todo = list(SoftDeleteMixin.__subclasses__())
782-
while todo:
783-
cls = todo.pop()
784-
if cls in seen:
785-
continue
786-
seen.add(cls)
787-
todo.extend(cls.__subclasses__())
788-
return sorted(seen, key=lambda c: c.__qualname__)
799+
return SoftDeleteMixin._registered_subclasses
789800

790801

791802
def _add_soft_delete_filter(execute_state: ORMExecuteState) -> None:

superset/security/manager.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3559,6 +3559,10 @@ def raise_for_ownership(self, resource: Model) -> None:
35593559
:param resource: The dashboard, dataset, chart, etc. resource
35603560
:raises SupersetSecurityException: If the current user is not an owner
35613561
"""
3562+
# Inline import: ``superset.models.helpers`` transitively imports
3563+
# ``superset.models.core``, which depends on lazily-initialised
3564+
# ``superset.feature_flag_manager``. A top-level import here would
3565+
# create a circular dependency (security ↔ models.core ↔ superset).
35623566
from superset.models.helpers import ( # pylint: disable=import-outside-toplevel # noqa: E501
35633567
SKIP_VISIBILITY_FILTER_CLASSES,
35643568
)
@@ -3579,6 +3583,20 @@ def raise_for_ownership(self, resource: Model) -> None:
35793583
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {resource.__class__}})
35803584
.get(resource.id)
35813585
)
3586+
# Explicit guard: ``orig_resource`` is ``None`` only if a parallel
3587+
# writer hard-deleted the row between the caller's load and this
3588+
# re-query. Falling through with ``owners=[]`` would surface as a
3589+
# misleading "ownership" error; raise the real cause instead.
3590+
if orig_resource is None:
3591+
raise SupersetSecurityException(
3592+
SupersetError(
3593+
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,
3594+
message=_(
3595+
"Resource was removed before ownership could be verified",
3596+
),
3597+
level=ErrorLevel.ERROR,
3598+
)
3599+
)
35823600
owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
35833601

35843602
if g.user.is_anonymous or g.user not in owners:

superset/views/filters.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# under the License.
1717
import logging
1818
from datetime import datetime
19-
from typing import Any, cast, Optional
19+
from typing import Any, cast, ClassVar, Optional
2020

2121
from flask import current_app as app, g
2222
from flask_appbuilder.models.filters import BaseFilter
@@ -26,7 +26,7 @@
2626

2727
from superset import security_manager
2828
from superset.extensions import db
29-
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
29+
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES, SoftDeleteMixin
3030

3131
logger = logging.getLogger(__name__)
3232

@@ -157,7 +157,11 @@ class BaseDeletedStateFilter(BaseFilter): # pylint: disable=too-few-public-meth
157157
"""
158158

159159
name = lazy_gettext("Deleted state")
160-
model: Any # set by subclass — a class with a ``deleted_at`` column
160+
# Subclasses bind ``model`` to a concrete ``SoftDeleteMixin``
161+
# subclass. Typed as ``type[SoftDeleteMixin]`` so a subclass that
162+
# accidentally binds to a non-soft-deletable entity fails mypy
163+
# rather than crashing at runtime on ``.deleted_at``.
164+
model: ClassVar[type[SoftDeleteMixin]]
161165

162166
def apply(self, query: Query, value: Any) -> Query:
163167
normalized = str(value).lower().strip() if value is not None else ""
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
"""Unit tests for ``find_existing_for_import`` /
18+
``clear_soft_deleted_for_import``.
19+
20+
These pin the side-effect-free / side-effect split that addresses
21+
Richard's review on PR #39977: ``find`` returns soft-deleted matches
22+
as-is so the importer can decide overwrite/permission before
23+
``clear_soft_deleted_for_import`` performs the destructive op.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
from collections.abc import Generator
29+
30+
import pytest
31+
from sqlalchemy import Column, Integer, String
32+
from sqlalchemy.orm import declarative_base
33+
from sqlalchemy.orm.session import Session
34+
from sqlalchemy_utils import UUIDType
35+
36+
from superset.commands.importers.v1.utils import (
37+
clear_soft_deleted_for_import,
38+
find_existing_for_import,
39+
)
40+
from superset.models.helpers import SoftDeleteMixin
41+
42+
_TestBase = declarative_base()
43+
44+
45+
class _ImportableSoftDeletable( # type: ignore[misc, valid-type]
46+
SoftDeleteMixin, _TestBase
47+
):
48+
__tablename__ = "_importable_soft_deletable_test"
49+
id = Column(Integer, primary_key=True)
50+
name = Column(String, nullable=False)
51+
uuid = Column(UUIDType(binary=False), unique=True)
52+
53+
54+
@pytest.fixture
55+
def _synthetic_table(session: Session) -> Generator[None, None, None]:
56+
_ImportableSoftDeletable.metadata.create_all(session.get_bind())
57+
yield
58+
_ImportableSoftDeletable.metadata.drop_all(session.get_bind())
59+
60+
61+
@pytest.mark.usefixtures("_synthetic_table")
62+
def test_find_returns_none_when_no_match(app_context: None, session: Session) -> None:
63+
"""No row with this UUID — function returns ``None``."""
64+
result = find_existing_for_import(
65+
_ImportableSoftDeletable,
66+
"00000000-0000-0000-0000-000000000000",
67+
)
68+
assert result is None
69+
70+
71+
@pytest.mark.usefixtures("_synthetic_table")
72+
def test_find_returns_live_row_as_is(
73+
app_context: None, session: Session, mocker: object
74+
) -> None:
75+
"""A live (not soft-deleted) row is returned as the existing row
76+
so the caller can treat it as an overwrite target."""
77+
import uuid as uuid_lib
78+
79+
obj_uuid = uuid_lib.uuid4()
80+
obj = _ImportableSoftDeletable(name="live", uuid=obj_uuid)
81+
session.add(obj)
82+
session.flush()
83+
84+
result = find_existing_for_import(_ImportableSoftDeletable, str(obj_uuid))
85+
assert result is not None
86+
assert result.deleted_at is None
87+
assert result.name == "live"
88+
89+
90+
@pytest.mark.usefixtures("_synthetic_table")
91+
def test_find_returns_soft_deleted_row_as_is(
92+
app_context: None, session: Session
93+
) -> None:
94+
"""A soft-deleted row is returned *with its ``deleted_at`` set*.
95+
Caller is responsible for deciding what to do with it — typically
96+
calling ``clear_soft_deleted_for_import`` after a permission
97+
check. The function does NOT hard-delete as a side effect.
98+
"""
99+
import uuid as uuid_lib
100+
101+
obj_uuid = uuid_lib.uuid4()
102+
obj = _ImportableSoftDeletable(name="deleted_target", uuid=obj_uuid)
103+
session.add(obj)
104+
session.flush()
105+
obj.soft_delete()
106+
session.flush()
107+
108+
result = find_existing_for_import(_ImportableSoftDeletable, str(obj_uuid))
109+
assert result is not None
110+
assert result.deleted_at is not None
111+
assert result.name == "deleted_target"
112+
113+
114+
@pytest.mark.usefixtures("_synthetic_table")
115+
def test_clear_hard_deletes_the_row(app_context: None, session: Session) -> None:
116+
"""``clear_soft_deleted_for_import`` calls
117+
``db.session.delete(existing)`` so the row is permanently removed
118+
(not just re-soft-deleted) and ORM ``after_delete`` listeners fire.
119+
A subsequent insert with the same UUID would not collide.
120+
"""
121+
import uuid as uuid_lib
122+
123+
obj_uuid = uuid_lib.uuid4()
124+
obj = _ImportableSoftDeletable(name="to_clear", uuid=obj_uuid)
125+
session.add(obj)
126+
session.flush()
127+
obj.soft_delete()
128+
session.flush()
129+
obj_id = obj.id
130+
131+
clear_soft_deleted_for_import(obj)
132+
133+
# Row is gone — bypass-aware lookup returns None.
134+
found = find_existing_for_import(_ImportableSoftDeletable, str(obj_uuid))
135+
assert found is None
136+
# And session.query confirms it's hard-deleted, not just soft.
137+
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
138+
139+
raw = (
140+
session.query(_ImportableSoftDeletable)
141+
.execution_options(
142+
**{SKIP_VISIBILITY_FILTER_CLASSES: {_ImportableSoftDeletable}}
143+
)
144+
.filter_by(id=obj_id)
145+
.one_or_none()
146+
)
147+
assert raw is None
148+
149+
150+
@pytest.mark.usefixtures("_synthetic_table")
151+
def test_find_then_clear_is_the_intended_caller_sequence(
152+
app_context: None, session: Session
153+
) -> None:
154+
"""The two functions compose as the documented contract: find
155+
first, decide based on the returned row, then clear if needed.
156+
Pinning this sequence ensures a refactor doesn't reverse the
157+
intent (find side-effects, clear pure)."""
158+
import uuid as uuid_lib
159+
160+
obj_uuid = uuid_lib.uuid4()
161+
obj = _ImportableSoftDeletable(name="reimport_target", uuid=obj_uuid)
162+
session.add(obj)
163+
session.flush()
164+
obj.soft_delete()
165+
session.flush()
166+
167+
# Step 1: find (pure). Caller now has the row to make decisions on.
168+
existing = find_existing_for_import(_ImportableSoftDeletable, str(obj_uuid))
169+
assert existing is not None
170+
assert existing.deleted_at is not None
171+
172+
# Step 2: caller decides to clear (in real code, after overwrite +
173+
# permission checks pass).
174+
clear_soft_deleted_for_import(existing)
175+
176+
# Step 3: caller can now re-insert with the same UUID without
177+
# colliding.
178+
fresh = _ImportableSoftDeletable(name="fresh", uuid=obj_uuid)
179+
session.add(fresh)
180+
session.flush()
181+
assert fresh.id is not None
182+
assert fresh.deleted_at is None

0 commit comments

Comments
 (0)