mirror of
https://github.com/apache/superset.git
synced 2026-06-01 05:39:17 +00:00
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
567 lines
19 KiB
Python
567 lines
19 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.
|
|
"""Tests for SoftDeleteMixin and the do_orm_execute visibility filter.
|
|
|
|
Synthetic models (``_SoftDeletable`` + ``_SoftDeletableTwo``) rather than
|
|
real Superset entities so the infrastructure is exercised in isolation
|
|
from any concrete adoption. Two soft-deletable models lets us pin
|
|
per-class scoping: a bypass for one class must not unhide soft-deleted
|
|
rows of the other.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Generator
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
from sqlalchemy import Column, ForeignKey, Integer, String
|
|
from sqlalchemy.orm import aliased, declarative_base, relationship
|
|
from sqlalchemy.orm.session import Session
|
|
|
|
from superset.models.helpers import (
|
|
skip_visibility_filter,
|
|
SKIP_VISIBILITY_FILTER_CLASSES,
|
|
SoftDeleteMixin,
|
|
)
|
|
|
|
_TestBase = declarative_base()
|
|
|
|
|
|
class _SoftDeletable(SoftDeleteMixin, _TestBase): # type: ignore[misc, valid-type]
|
|
"""In-memory synthetic model for exercising SoftDeleteMixin."""
|
|
|
|
__tablename__ = "_soft_deletable_test"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String, nullable=False)
|
|
|
|
|
|
class _SoftDeletableTwo(SoftDeleteMixin, _TestBase): # type: ignore[misc, valid-type]
|
|
"""A second soft-deletable model so isolation tests can prove a
|
|
bypass for one class does not affect the other."""
|
|
|
|
__tablename__ = "_soft_deletable_test_two"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String, nullable=False)
|
|
|
|
|
|
class _SoftDeletableParent(_TestBase): # type: ignore[misc, valid-type]
|
|
"""A non-soft-deletable parent so relationship-load tests can verify
|
|
that ``with_loader_criteria``'s ``propagate_to_loaders`` carries the
|
|
per-class criteria along to lazy/selectin loads."""
|
|
|
|
__tablename__ = "_soft_deletable_parent"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String)
|
|
children = relationship("_SoftDeletableChild", back_populates="parent")
|
|
|
|
|
|
class _SoftDeletableChild(SoftDeleteMixin, _TestBase): # type: ignore[misc, valid-type]
|
|
__tablename__ = "_soft_deletable_child"
|
|
|
|
id = Column(Integer, primary_key=True)
|
|
name = Column(String, nullable=False)
|
|
parent_id = Column(Integer, ForeignKey("_soft_deletable_parent.id"))
|
|
parent = relationship("_SoftDeletableParent", back_populates="children")
|
|
|
|
|
|
@pytest.fixture
|
|
def _synthetic_tables(session: Session) -> Generator[None, None, None]:
|
|
"""Create the synthetic tables for the test session and drop them after."""
|
|
_TestBase.metadata.create_all(session.get_bind())
|
|
yield
|
|
_TestBase.metadata.drop_all(session.get_bind())
|
|
|
|
|
|
@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."""
|
|
obj = _SoftDeletable(name="row1")
|
|
session.add(obj)
|
|
session.flush()
|
|
|
|
assert obj.deleted_at is None
|
|
assert not obj.is_deleted
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
|
|
assert obj.deleted_at is not None
|
|
assert isinstance(obj.deleted_at, datetime)
|
|
assert obj.is_deleted
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_restore_clears_deleted_at(app_context: None, session: Session) -> None:
|
|
"""restore() clears deleted_at back to None."""
|
|
obj = _SoftDeletable(name="row1")
|
|
session.add(obj)
|
|
session.flush()
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
assert obj.is_deleted
|
|
|
|
obj.restore()
|
|
session.flush()
|
|
assert obj.deleted_at is None
|
|
assert not obj.is_deleted
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_where_not_deleted_filter_clause(app_context: None, session: Session) -> None:
|
|
"""where_not_deleted() returns a SQL WHERE clause usable in queries."""
|
|
active = _SoftDeletable(name="active")
|
|
deleted = _SoftDeletable(name="deleted")
|
|
session.add_all([active, deleted])
|
|
session.flush()
|
|
|
|
deleted.soft_delete()
|
|
session.flush()
|
|
|
|
results = (
|
|
session.query(_SoftDeletable)
|
|
.filter(_SoftDeletable.where_not_deleted())
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.all()
|
|
)
|
|
|
|
assert len(results) == 1
|
|
assert results[0].name == "active"
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_global_filter_excludes_soft_deleted_rows(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""The do_orm_execute listener excludes soft-deleted rows by default."""
|
|
obj = _SoftDeletable(name="will_be_deleted")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
result = (
|
|
session.query(_SoftDeletable).filter(_SoftDeletable.id == obj_id).one_or_none()
|
|
)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_listener_adapts_criteria_to_aliased_table_in_joins(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""When the same soft-deletable table appears under an alias in a
|
|
JOIN (e.g., ``slices AS chart``), the listener's loader_criteria
|
|
must reference the **alias**, not the raw table name. Passing the
|
|
criteria as ``Slice.deleted_at.is_(None)`` (a concrete SQL
|
|
expression bound to the base class) renders as
|
|
``slices.deleted_at`` even when the statement aliases
|
|
``slices AS chart`` — producing
|
|
``Unknown column 'slices.deleted_at' in 'on clause'``. The fix is
|
|
to pass the criteria as a lambda so SQLAlchemy adapts the column
|
|
reference per occurrence.
|
|
|
|
This regression test reproduces the FAB shape that surfaced the
|
|
bug on the chart-rollout PR: a parent table joined to an aliased
|
|
soft-deletable child. The query must run without
|
|
``OperationalError``.
|
|
"""
|
|
parent_a = _SoftDeletableParent(name="p_with_child")
|
|
child = _SoftDeletableChild(name="c1")
|
|
parent_a.children = [child]
|
|
session.add(parent_a)
|
|
session.flush()
|
|
session.expunge_all()
|
|
|
|
# Alias the child so the JOIN renders as ``_soft_deletable_child AS
|
|
# aliased_child`` — same shape as FAB's ``slices AS chart``.
|
|
aliased_child = aliased(_SoftDeletableChild)
|
|
results = (
|
|
session.query(_SoftDeletableParent, aliased_child)
|
|
.outerjoin(aliased_child, _SoftDeletableParent.id == aliased_child.parent_id)
|
|
.all()
|
|
)
|
|
# Query must execute without OperationalError. The exact result
|
|
# shape isn't the point — that the listener-attached criteria
|
|
# references the alias rather than the raw table name is.
|
|
assert len(results) == 1
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_listener_does_not_affect_non_soft_deletable_queries(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""Queries against a class that does NOT inherit ``SoftDeleteMixin``
|
|
pass through the listener unchanged. The listener still iterates
|
|
soft-delete subclasses and attaches a ``with_loader_criteria`` per
|
|
class to every primary SELECT, but each is a no-op when the targeted
|
|
class isn't in the statement. Pins that the listener does not
|
|
silently break unrelated queries.
|
|
"""
|
|
parent_a = _SoftDeletableParent(name="a")
|
|
parent_b = _SoftDeletableParent(name="b")
|
|
session.add_all([parent_a, parent_b])
|
|
session.flush()
|
|
|
|
rows = session.query(_SoftDeletableParent).order_by(_SoftDeletableParent.id).all()
|
|
assert [r.name for r in rows] == ["a", "b"]
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_per_query_class_bypass_returns_soft_deleted_rows(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""Per-query bypass set on ``execution_options`` (scoped to a specific
|
|
class) makes that class's soft-deleted rows visible. Used by
|
|
``BaseDAO.find_by_id(skip_visibility_filter=True)``,
|
|
``find_existing_for_import``, and ``raise_for_ownership``.
|
|
"""
|
|
obj = _SoftDeletable(name="soon_deleted")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
visible = (
|
|
session.query(_SoftDeletable)
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.filter(_SoftDeletable.id == obj_id)
|
|
.one_or_none()
|
|
)
|
|
assert visible is not None
|
|
assert visible.name == "soon_deleted"
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_per_query_bypass_for_one_class_does_not_unhide_other(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""A per-query bypass scoped to ``_SoftDeletable`` does not let a
|
|
statement that also queries ``_SoftDeletableTwo`` see its soft-deleted
|
|
rows. Pins per-class scoping at the listener level: the loader
|
|
criteria evaluates the bypass set per concrete subclass, not as a
|
|
blanket exemption.
|
|
"""
|
|
one = _SoftDeletable(name="one")
|
|
two = _SoftDeletableTwo(name="two")
|
|
session.add_all([one, two])
|
|
session.flush()
|
|
one.soft_delete()
|
|
two.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
# Bypass scoped to _SoftDeletable only.
|
|
one_seen = (
|
|
session.query(_SoftDeletable)
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.one_or_none()
|
|
)
|
|
assert one_seen is not None
|
|
assert one_seen.name == "one"
|
|
|
|
# Same execution_options on a query for the OTHER class. _SoftDeletableTwo
|
|
# is not in the bypass set, so it stays filtered.
|
|
two_seen = (
|
|
session.query(_SoftDeletableTwo)
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.one_or_none()
|
|
)
|
|
assert two_seen is None
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_get_without_bypass_filters_out_soft_deleted_row(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""Baseline: ``Query.get()`` without bypass does not find soft-deleted
|
|
rows. ``session.expunge_all()`` empties the identity map so ``.get()``
|
|
is forced to issue SQL through the listener.
|
|
"""
|
|
obj = _SoftDeletable(name="hidden_by_listener")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expunge_all()
|
|
|
|
result = session.query(_SoftDeletable).get(obj_id)
|
|
assert result is None, (
|
|
".get() with no bypass and an empty identity map should be filtered "
|
|
"by the listener"
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_per_query_bypass_via_get_finds_soft_deleted_row(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""Per-query class bypass propagates through ``Query.get()`` — the
|
|
path ``security_manager.raise_for_ownership`` relies on. Identity-map
|
|
cleared via ``session.expunge_all()`` to force SQL through the
|
|
listener.
|
|
"""
|
|
obj = _SoftDeletable(name="per_query_via_get")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expunge_all()
|
|
|
|
result = (
|
|
session.query(_SoftDeletable)
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.get(obj_id)
|
|
)
|
|
|
|
assert result is not None, (
|
|
"per-query class bypass should let .get() find soft-deleted row"
|
|
)
|
|
assert result.deleted_at is not None
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_session_bypass_survives_query_reconstruction(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""The FAB list-endpoint failure mode: a derived query is built from
|
|
a fresh ``session.query(Model)`` (no ``execution_options``) and
|
|
joined to a previously-filtered subquery. Per-query
|
|
``execution_options`` would not survive that construction. Per-session
|
|
bypass via ``session.info`` does — because the listener reads from
|
|
``execute_state.session.info`` regardless of how the statement was
|
|
built.
|
|
"""
|
|
obj = _SoftDeletable(name="visible_via_session_bypass")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
# Set up a FAB-style fork: an "inner" query that selects the row's id
|
|
# via the bypass, then an outer query that joins to the inner as a
|
|
# subquery — but the outer query is built fresh from session.query()
|
|
# and has NO execution_options on it. This is exactly the shape that
|
|
# SQLAInterface.get_outer_query_from_inner_query produces.
|
|
session.info[SKIP_VISIBILITY_FILTER_CLASSES] = {_SoftDeletable}
|
|
|
|
inner_subquery = (
|
|
session.query(_SoftDeletable.id).filter(_SoftDeletable.id == obj_id).subquery()
|
|
)
|
|
outer = session.query(_SoftDeletable).join(
|
|
inner_subquery, _SoftDeletable.id == inner_subquery.c.id
|
|
)
|
|
rows = outer.all()
|
|
|
|
# Clean up so subsequent tests in the same session see a fresh state.
|
|
session.info.pop(SKIP_VISIBILITY_FILTER_CLASSES, None)
|
|
|
|
assert len(rows) == 1
|
|
assert rows[0].id == obj_id
|
|
assert rows[0].deleted_at is not None
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_session_bypass_does_not_leak_across_classes(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""A session-level bypass for ``_SoftDeletable`` only does NOT make
|
|
soft-deleted ``_SoftDeletableTwo`` rows visible — even though both
|
|
queries run on the same session that has the bypass flag set. The
|
|
per-class scoping in the listener's loader-criteria lambda is what
|
|
prevents this leak.
|
|
"""
|
|
one = _SoftDeletable(name="one")
|
|
two = _SoftDeletableTwo(name="two")
|
|
session.add_all([one, two])
|
|
session.flush()
|
|
one.soft_delete()
|
|
two.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
session.info[SKIP_VISIBILITY_FILTER_CLASSES] = {_SoftDeletable}
|
|
try:
|
|
one_seen = session.query(_SoftDeletable).one_or_none()
|
|
two_seen = session.query(_SoftDeletableTwo).one_or_none()
|
|
finally:
|
|
session.info.pop(SKIP_VISIBILITY_FILTER_CLASSES, None)
|
|
|
|
assert one_seen is not None
|
|
assert one_seen.name == "one"
|
|
assert two_seen is None, (
|
|
"_SoftDeletableTwo should still be filtered — only _SoftDeletable "
|
|
"is in the bypass set"
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_context_manager_adds_and_removes_bypass(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""``skip_visibility_filter`` context manager adds classes on entry,
|
|
removes them on exit, restoring the prior visibility state.
|
|
"""
|
|
obj = _SoftDeletable(name="cm_target")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
obj.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
# Filtered before the block.
|
|
assert (
|
|
session.query(_SoftDeletable).filter(_SoftDeletable.id == obj_id).one_or_none()
|
|
is None
|
|
)
|
|
|
|
# Visible inside the block.
|
|
with skip_visibility_filter(session, _SoftDeletable):
|
|
inside = (
|
|
session.query(_SoftDeletable)
|
|
.filter(_SoftDeletable.id == obj_id)
|
|
.one_or_none()
|
|
)
|
|
assert inside is not None
|
|
assert inside.id == obj_id
|
|
|
|
# Filtered again after the block (state restored).
|
|
session.expire_all()
|
|
assert (
|
|
session.query(_SoftDeletable).filter(_SoftDeletable.id == obj_id).one_or_none()
|
|
is None
|
|
)
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_context_manager_nested_preserves_outer_scope(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""Nested ``skip_visibility_filter`` blocks compose correctly: an
|
|
inner block only removes the classes it added, so the outer block's
|
|
bypass remains in effect after the inner exits.
|
|
"""
|
|
one = _SoftDeletable(name="outer_target")
|
|
two = _SoftDeletableTwo(name="inner_target")
|
|
session.add_all([one, two])
|
|
session.flush()
|
|
one.soft_delete()
|
|
two.soft_delete()
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
with skip_visibility_filter(session, _SoftDeletable):
|
|
# Outer bypass: _SoftDeletable visible, _SoftDeletableTwo still filtered.
|
|
assert session.query(_SoftDeletable).one_or_none() is not None
|
|
assert session.query(_SoftDeletableTwo).one_or_none() is None
|
|
|
|
with skip_visibility_filter(session, _SoftDeletableTwo):
|
|
# Both visible inside the inner block.
|
|
session.expire_all()
|
|
assert session.query(_SoftDeletable).one_or_none() is not None
|
|
assert session.query(_SoftDeletableTwo).one_or_none() is not None
|
|
|
|
# Inner exited — _SoftDeletableTwo back to filtered, outer still
|
|
# in effect.
|
|
session.expire_all()
|
|
assert session.query(_SoftDeletable).one_or_none() is not None
|
|
assert session.query(_SoftDeletableTwo).one_or_none() is None
|
|
|
|
# Outermost exited — both filtered.
|
|
session.expire_all()
|
|
assert session.query(_SoftDeletable).one_or_none() is None
|
|
assert session.query(_SoftDeletableTwo).one_or_none() is None
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_relationship_load_filters_child_independently_of_parent_bypass(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""``with_loader_criteria(..., propagate_to_loaders=True)`` carries
|
|
the criteria *function* along to relationship loads, where it is
|
|
re-evaluated per concrete child class. So even if the parent's
|
|
statement is processed with a bypass set, a lazy/selectin load of
|
|
soft-deletable children whose class is NOT in the bypass set still
|
|
gets filtered.
|
|
"""
|
|
parent = _SoftDeletableParent(name="p")
|
|
live_child = _SoftDeletableChild(name="live")
|
|
deleted_child = _SoftDeletableChild(name="deleted")
|
|
parent.children = [live_child, deleted_child]
|
|
session.add(parent)
|
|
session.flush()
|
|
deleted_child.soft_delete()
|
|
session.flush()
|
|
session.expunge_all()
|
|
|
|
# Parent itself isn't soft-deletable; bypass set has no effect on the
|
|
# parent query. But _SoftDeletableChild is soft-deletable, and is NOT
|
|
# in the bypass set, so children should be filtered via propagation.
|
|
session.info[SKIP_VISIBILITY_FILTER_CLASSES] = {_SoftDeletable}
|
|
try:
|
|
p = session.query(_SoftDeletableParent).first()
|
|
assert p is not None
|
|
kids = list(p.children)
|
|
finally:
|
|
session.info.pop(SKIP_VISIBILITY_FILTER_CLASSES, None)
|
|
|
|
assert [c.name for c in kids] == ["live"]
|
|
|
|
|
|
@pytest.mark.usefixtures("_synthetic_tables")
|
|
def test_session_delete_permanently_removes_row(
|
|
app_context: None, session: Session
|
|
) -> None:
|
|
"""session.delete() permanently removes the row (hard delete). The
|
|
mixin does not intercept session.delete() — that is handled by
|
|
BaseDAO.delete() routing to soft_delete() at the DAO level."""
|
|
obj = _SoftDeletable(name="hard_delete_test")
|
|
session.add(obj)
|
|
session.flush()
|
|
obj_id = obj.id
|
|
|
|
session.delete(obj)
|
|
session.flush()
|
|
session.expire_all()
|
|
|
|
result = (
|
|
session.query(_SoftDeletable)
|
|
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {_SoftDeletable}})
|
|
.filter(_SoftDeletable.id == obj_id)
|
|
.one_or_none()
|
|
)
|
|
assert result is None
|